its-swiss 0.1.0 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 844fa90d3877d55f3c22b850b00bedfe6156241216b1165aad929ad3540edbd8
4
- data.tar.gz: 470159b689994419fb692d26c0d1ede1cd48c73508090a4cb9f472ffa95788cc
3
+ metadata.gz: 07e25008eecb7b3bdc7e35b37c934d1c948e30f36bca49283798499740fc6c14
4
+ data.tar.gz: 987b4c9b997effa03a2b2071a7d7072580b36d3655d3f760a6299b5e80e3eab4
5
5
  SHA512:
6
- metadata.gz: 40e2d6de3e683bc1adffbaabefd3863e3886ce7462c4e071ed916ff45a743f08b1d590535b6c0732821fa56017299d182045c631da1a1e30a9f84e3c00b5d666
7
- data.tar.gz: e3ef3c8e93c892330619ad27ac091a0e9a3e9f3b8613db69dda8ed5ddf0da0548e406f83912770a1cfd010612380481906060cc8163aebc85d682846d7b20e52
6
+ metadata.gz: 2651041e9048e42c29b62456ebd9d9c8f3ad4077315ea8b73c5e839442c7994031d3e7336efcf02435326da91daa3d5ec853919d906664350fc9537c9a799a08
7
+ data.tar.gz: 8ec6e22df73670a4a118cf10d60a87fc756f5fdae210a2134f3a9c3a38be5210ef30187518046f638a58abff2e4c02b463f809ee7a57d22de32eaf8bd17573db
data/CHANGELOG.md CHANGED
@@ -2,6 +2,184 @@
2
2
 
3
3
  Semver. Consumers pin `~> 0.1`.
4
4
 
5
+ ## 0.3.0 — 2026-09-01
6
+
7
+ The vertical rhythm, twice: once to put every box on the baseline, and again
8
+ to put the type on it. Found from outside, in a page that embedded the
9
+ specimen and turned the baseline overlay on — the grid was drawn correctly and
10
+ nothing sat on it.
11
+
12
+ Rendering moves. Every ruled component is a pixel or two shorter, and where a
13
+ browser can trim a text box the leading comes out of the block, so a page is
14
+ tighter than 0.2.0 by a few pixels per block. Read this section before
15
+ upgrading.
16
+
17
+ ### Fixed
18
+
19
+ - **The vertical rhythm was decorative.** Every line box was a whole number of
20
+ baselines and the page was still off the grid, because a line box is not a
21
+ box: wherever a rule sat on a padded edge, its width was *added* to a box
22
+ whose padding was already whole baselines. A hairline is a pixel, so each
23
+ one moved everything below it by a pixel — and since the masthead, the
24
+ footer, `hr`, every table row, every field control, every button, the
25
+ pagination and the errors block all did it, the error accumulated down the
26
+ column rather than showing up once. On the specimen the page was one pixel
27
+ out by the first heading and seven by the footer.
28
+
29
+ Every rule now comes out of the padding it sits on — `calc(var(--space-1) -
30
+ var(--rule-hair))` — and `hr` draws its rule on the top edge of a box one
31
+ baseline tall instead of compensating in a margin, because a margin
32
+ compensation collapses away exactly where a rule leads or precedes a
33
+ section. Boxes that change height: a button and a table row are 40px rather
34
+ than 42 and 41, a field control 32 rather than 33, a `select` 32 rather than
35
+ 29.
36
+ - **A token in a line of prose grew the line.** `code`, `kbd` and `samp` set
37
+ in the mono stack are a second font on the line, with an ascent and a
38
+ descent of their own that the line box had to hold — a pixel at body size,
39
+ four under the page title, and only on the lines that happened to mention a
40
+ token. Their leading is now zero, which leaves the line box to the strut;
41
+ the glyphs are untouched.
42
+ - **`select` ignored its leading.** `font: inherit` hands a control the family
43
+ and the size and puts its line box back to `normal`, so a select stood 29px
44
+ where every other control stood 32. It is now given the height the rest of
45
+ the ladder produces.
46
+ - **A checkbox row was 20px.** The label beside a checkbox took the micro
47
+ register's 16px line box, which is not the row a 13px control sits on. It
48
+ now takes the same 24px line the library already gives the micro type you
49
+ can tap.
50
+ - **Table rows landed on half pixels.** With `border-collapse: collapse` the
51
+ shared rule belongs to the boundary rather than to either cell, and the
52
+ browser splits it: every row sat at `x.5` and the header row was half a
53
+ pixel short. Nothing in the library draws a border two cells could share, so
54
+ borders are separate — with the spacing still zero — and a row is exactly as
55
+ tall as the ladder says.
56
+
57
+ ### Added
58
+
59
+ - **The type sits on the baseline now, not merely in step with it.** Boxes in
60
+ step are not a baseline grid. Where a line's baseline falls inside its line
61
+ box depends on the font's ascent and the leading either side of it, so at
62
+ 0.2.0 a paragraph's baselines sat a pixel off the grid, a caption's four,
63
+ and the two were three pixels out of register with each other — every
64
+ register keeping its own grid, none of them the page's.
65
+
66
+ Every text register is now trimmed — `text-box: trim-both cap alphabetic` —
67
+ which makes a block's over edge the cap of its first line and its under edge
68
+ the baseline of its last. What then separates the box from the grid is the
69
+ cap height, and one padding rounds it up: `calc(round(up, 1cap,
70
+ var(--baseline)) - 1cap)`, measured by the browser in cap units, so a
71
+ library that leaves the typeface to the application still never has to be
72
+ told the font's metrics. It is published as `--cap-correction`, which any
73
+ component setting its own padding adds to it — `calc(var(--space-1) +
74
+ var(--cap-correction))` is the right padding in both paths.
75
+
76
+ Behind `@supports`, and the fallback is the box rhythm below: Chromium and
77
+ Safari trim, Firefox does not yet. **The two do not render identically.**
78
+ Trimming takes the leading out of a block's own box, so the spacing you
79
+ declare is the spacing you see; a trimmed page is a few pixels tighter per
80
+ block than the same page in Firefox, and the ladder means what it says
81
+ rather than what it says plus half a line. Both stay on the grid.
82
+
83
+ Controls opt out — a button is five baselines of box with its label centred,
84
+ and trimming the label would take the box with it — as does any block whose
85
+ content is not type. `text-box: normal; padding-block-start: 0` is how.
86
+
87
+ ### Fixed
88
+
89
+ - **`rake release` did not exist.** The release workflow runs
90
+ `bundle exec rake release`, which is Bundler's task and arrives with
91
+ `require "bundler/gem_tasks"` — which the Rakefile did not have. So a
92
+ release passed every check it makes, minted its credentials over OIDC, and
93
+ stopped at *Don't know how to build task 'release'*. 0.2.0 never reached
94
+ RubyGems for this reason, and 0.1.0 only did because it was pushed by hand
95
+ before the workflow existed, which the "already published" guard then read
96
+ as nothing to do. Guarded now, along with the workflow's own tag-versus-
97
+ gemspec check.
98
+ - **Every browser test was skipping in CI.** The suite looks for a browser and
99
+ a driver, and a candidate found on the `PATH` came back as the bare name it
100
+ was looked up by. Selenium wants a file — given `"chromedriver"` it raises
101
+ *not a file* — so the harness caught that, fell back to `rack_test` and
102
+ skipped, on a runner that had just installed a driver for it. The job was
103
+ green and had checked nothing: 12 runs, 12 skips. Candidates now resolve to
104
+ where they actually are, and the CI job now names the browser and the driver
105
+ the same step installed — the runner image ships a chromedriver of its own,
106
+ and a driver a major version ahead of the browser refuses to start a session
107
+ at all — and sets `REQUIRE_BROWSER`, which turns a skip into a failure — a skipped browser test is invisible in a passing
108
+ job, and the assertions that need a browser are the ones about what the
109
+ cascade and the box model actually did.
110
+ - **The `:head` slot rendered before the library's own stylesheets**, so a
111
+ layer named there took its place in the order ahead of every layer the
112
+ library declares and lost to all of them. The specimen's own furniture is
113
+ one such stylesheet, and it could not override the library it documents. The
114
+ slot now comes after.
115
+
116
+ ### Guards added
117
+
118
+ - **Every register's last baseline is on a baseline**, measured in the
119
+ browser. Under `trim-both … alphabetic` a block's under edge *is* that
120
+ baseline, so measuring the box measures the type — and it is measured rather
121
+ than probed on purpose: inserting a span to read a baseline re-lays out a
122
+ trimmed page and moves the thing it was measuring. Skips where the browser
123
+ cannot trim.
124
+ - **The correction is zero where the browser cannot trim**, read from the
125
+ source, so the enhancement can never become a requirement.
126
+ - **Every box on the specimen starts on a baseline and is a whole number of
127
+ them tall**, measured in the browser on the page that holds one of
128
+ everything. The suite already asserted that every *line box* was a whole
129
+ number of baselines, which was true throughout and is what let all of the
130
+ above through.
131
+ - The leading guard now admits `0`, the one value that is not a measurement:
132
+ an inline box that takes no part in the line it sits on.
133
+
134
+ ## 0.2.0 — 2026-08-31
135
+
136
+ Everything here was found by the second consumer inside a day of building on
137
+ 0.1.0, which is roughly the point of having one.
138
+
139
+ ### Fixed
140
+
141
+ - **The pagination partial printed its own source.** Its opening comment held
142
+ a worked example written in ERB; the example's own closing delimiter ended
143
+ the comment, and the two lines after it were emitted as page content. On
144
+ every page that rendered it — including the specimen, the library's own
145
+ documentation and regression fixture, for the whole of 0.1.0. The example
146
+ now lives in the README, where it can be quoted.
147
+ - **`.table .numeric` zeroed the end padding of every numeric cell**, not just
148
+ one in the final column, because it set the padding with the `padding-inline`
149
+ shorthand. Anywhere else in a table the next column's text began exactly
150
+ where the number ended. Now sets only `padding-inline-start`, and leaves the
151
+ final column to the `:last-child` rule that already handled it.
152
+
153
+ ### Changed
154
+
155
+ - **`its_swiss:install` generates a nested layout** — `app/views/layouts/
156
+ application.html.erb` ending in `render template: "layouts/its_swiss/shell"`
157
+ — instead of injecting `layout "its_swiss/shell"` into `ApplicationController`.
158
+ The shell is filled through `content_for`, which has to run while a view is
159
+ rendering; naming it on the controller rendered the shell but left nowhere
160
+ to fill it once, so an application wrote its masthead into every view. The
161
+ generated layout also **links `theme.css`**, which nothing did before: an
162
+ application that followed the README exactly got no accent and no grid, with
163
+ no error anywhere.
164
+
165
+ Existing installs keep working. To move: delete the `layout` line from
166
+ `ApplicationController` and add the layout, or re-run the generator.
167
+
168
+ ### Guards added
169
+
170
+ Three of the four findings were in views, a generator and a form builder —
171
+ surfaces a CSS library's tests do not reach. Two new ones, both of which fail
172
+ on the bugs above:
173
+
174
+ - The rendered specimen contains no template delimiters. Every other assertion
175
+ in that file was about markup that should be present; none about output that
176
+ should not be.
177
+ - No view's comment quotes ERB. A comment ends at the first closing delimiter
178
+ it meets, so an example inside one is an example that truncates it.
179
+
180
+ The specimen's table now has a numeric column that is not last, and a browser
181
+ test measures its end padding — the case that broke.
182
+
5
183
  ## 0.1.0 — 2026-08-31
6
184
 
7
185
  The first extraction, from Pandatone, which ships on it.
@@ -53,5 +231,7 @@ They are in the boundary the handoff drew and they break the library's own
53
231
  rule about two applications; they are the two components most likely to move
54
232
  when Stripeclub lands.
55
233
 
234
+ *(Both of them shipped broken. See 0.2.0.)*
235
+
56
236
  `.footer` is the same shape of guess — the shell has the slot and Pandatone
57
237
  does not fill it.
data/README.md CHANGED
@@ -14,7 +14,7 @@ the consuming application's to set — it is not a theme with the colour left
14
14
  out, it is a style whose argument is that the values are enough.
15
15
 
16
16
  ```ruby
17
- gem "its-swiss"
17
+ gem "its-swiss", "~> 0.1"
18
18
  ```
19
19
 
20
20
  ```sh
@@ -63,6 +63,44 @@ holding all of them. Nothing here has a default the gem could pick honestly.
63
63
  | `--font-family` | The typeface. The gem ships none — declare `@font-face` and name it |
64
64
  | `--value-chroma`, `--value-hue` | Warms the whole value scale together. Neutral as shipped |
65
65
  | `--columns`, `--gutter`, `--baseline` | How many fields this problem has, and the unit everything vertical is measured in |
66
+ | `--cap-correction` | Read, not set: what a trimmed register adds above its cap to reach the next baseline |
67
+
68
+ ### The baseline
69
+
70
+ Everything vertical is measured in baselines, and the type sits on them rather
71
+ than merely in step with them.
72
+
73
+ Boxes in step are the easy half: line boxes and spacing are whole numbers of
74
+ `--baseline`, so blocks that start together stay together down the column.
75
+ That alone is not a baseline grid — where a line's baseline falls inside its
76
+ line box depends on the font's ascent and the leading either side of it, so a
77
+ caption and a paragraph can both be in step and still be three pixels out of
78
+ register with each other.
79
+
80
+ So every text register is trimmed to its own type: `text-box: trim-both cap
81
+ alphabetic` makes a block's over edge the cap of its first line and its under
82
+ edge the baseline of its last, and one padding rounds the cap height up to the
83
+ next baseline —
84
+
85
+ ```css
86
+ padding-block-start: calc(round(up, 1cap, var(--baseline)) - 1cap);
87
+ ```
88
+
89
+ — which the browser computes in cap units, so the library still never has to
90
+ be told the font's metrics. It is published as `--cap-correction`: a component
91
+ that sets its own padding on trimmed text adds it, as
92
+ `calc(var(--space-1) + var(--cap-correction))`, which is the right padding
93
+ whether or not the browser trims.
94
+
95
+ Two things opt out, with `text-box: normal; padding-block-start: 0`: a control,
96
+ whose box is the target and whose label is centred in it, and any block whose
97
+ content is not type — an image, a swatch, a diagram.
98
+
99
+ It is behind `@supports`, and the fallback is the box rhythm above. Chromium
100
+ and Safari trim; Firefox does not yet. The two do not render identically:
101
+ trimming takes the leading out of a block's own box, so a trimmed page is a
102
+ few pixels tighter per block and the ladder means what it says rather than
103
+ what it says plus half a line. Both stay on the grid.
66
104
 
67
105
  ### The value scale
68
106
 
@@ -126,12 +164,28 @@ it stops on a field line:
126
164
 
127
165
  ## The layout shell
128
166
 
129
- ```ruby
130
- class ApplicationController < ActionController::Base
131
- layout "its_swiss/shell"
132
- end
167
+ The installer writes `app/views/layouts/application.html.erb` as a layout
168
+ **for** the shell rather than one instead of it:
169
+
170
+ ```erb
171
+ <% content_for :head do %>
172
+ <%= stylesheet_link_tag "theme", "data-turbo-track": "reload" %>
173
+ <% end %>
174
+
175
+ <% content_for :mark do %><%= link_to "Your app", root_path %><% end %>
176
+
177
+ <%= render template: "layouts/its_swiss/shell" %>
133
178
  ```
134
179
 
180
+ Nested, not `layout "its_swiss/shell"` on a controller. `content_for` has to
181
+ run while a view is rendering, and these slots are set once for the whole
182
+ application — naming the shell on the controller renders it but leaves
183
+ nowhere to fill it, so every view ends up writing the same masthead.
184
+
185
+ Note the `:head` slot links `theme.css`. The shell links the library's six
186
+ stylesheets and stops; the accent and the grid live in yours, and nothing
187
+ links it but this.
188
+
135
189
  Slots, all optional:
136
190
 
137
191
  | Slot | |
@@ -157,6 +211,19 @@ beyond the shell is the application's, for the same reason its grid is.
157
211
  | `its_swiss_form_with(...)` | `form_with`, already holding the library's builder |
158
212
  | `its_swiss_page_numbers(page, pages)` | Which numbers a run of them shows, elided |
159
213
 
214
+ ### Pagination
215
+
216
+ The library has no paginator and no opinion about which one you use — it takes
217
+ a page, a total, and something that turns a number into a URL:
218
+
219
+ ```erb
220
+ <%= render "its_swiss/shared/pagination",
221
+ page: @page, pages: @pages, url: ->(n) { colors_path(page: n) } %>
222
+ ```
223
+
224
+ Long runs are elided around the current page. `window:` (default 2) sets how
225
+ many neighbours show; `label:` names the `<nav>` for a screen reader.
226
+
160
227
  ## The form builder
161
228
 
162
229
  One shape for every field: a label, a control, and — when there is something to
@@ -211,10 +278,11 @@ every line box is measured in baselines, that no signal rests on colour alone.
211
278
  The rest needs a browser, because a rule on the wrong selector reads correctly
212
279
  in the CSS and does nothing on a page. Those assert what Chromium actually
213
280
  resolved: that an unlayered declaration beats the library's layered one, that
214
- the accent unset is ink, that quiet ink clears 4.5:1, that every line box is a
215
- whole number of baselines, that nothing escapes the page at 390px, and that the
216
- measure lands on a field line the browser really laid out. They skip loudly
217
- rather than pretending to have checked.
281
+ the accent unset is ink, that quiet ink clears 4.5:1, that every box on the
282
+ specimen starts on a baseline and is a whole number of them tall, that every
283
+ register's last baseline lands on one, that nothing escapes the page at 390px,
284
+ and that the measure lands on a field line the browser really laid out. They
285
+ skip loudly rather than pretending to have checked.
218
286
 
219
287
  There are no pixel tests.
220
288
 
@@ -22,7 +22,7 @@
22
22
  flex-wrap: wrap;
23
23
  align-items: baseline;
24
24
  gap: var(--space-2) var(--space-4);
25
- padding-block: var(--space-3);
25
+ padding-block: var(--space-3) calc(var(--space-3) - var(--rule-hair));
26
26
  border-bottom: var(--rule-hair) solid var(--rule);
27
27
  }
28
28
 
@@ -68,7 +68,7 @@
68
68
 
69
69
  .footer {
70
70
  margin-top: var(--space-13);
71
- padding-block: var(--space-3);
71
+ padding-block: calc(var(--space-3) - var(--rule-hair)) var(--space-3);
72
72
  border-top: var(--rule-hair) solid var(--rule);
73
73
  font-size: var(--size-1);
74
74
  line-height: var(--space-3);
@@ -78,11 +78,19 @@
78
78
  The one horizontal mark in the style, at the two weights tokens.css
79
79
  declares. */
80
80
 
81
+ /* The rule is drawn on the top edge of a box one baseline tall rather than
82
+ on a box of no height at all, so its width is inside the box and both
83
+ margins stay whole baselines. Compensating in a margin instead looks
84
+ right until the margin collapses — a rule leading a section loses its
85
+ top margin to the section's, a rule before one loses its bottom margin to
86
+ the same — and a compensation that has collapsed away is a pixel back on
87
+ the page. Both weights fit the same box, which is why the heavy one only
88
+ changes the drawing. */
81
89
  hr {
82
- height: 0;
90
+ height: var(--space-1);
83
91
  border: 0;
84
92
  border-top: var(--rule-hair) solid var(--rule);
85
- margin-block: var(--space-3);
93
+ margin-block: var(--space-3) var(--space-2);
86
94
  }
87
95
 
88
96
  hr.rule--heavy { border-top-width: var(--rule-heavy); border-color: var(--ink); }
@@ -104,7 +112,7 @@
104
112
 
105
113
  .table th,
106
114
  .table td {
107
- padding-block: var(--space-1);
115
+ padding-block: calc(var(--space-1) + var(--cap-correction)) calc(var(--space-1) - var(--rule-hair));
108
116
  padding-inline-end: var(--space-3);
109
117
  border-bottom: var(--rule-hair) solid var(--rule);
110
118
  vertical-align: baseline;
@@ -120,8 +128,13 @@
120
128
  .table td:last-child,
121
129
  .table th:last-child { padding-inline-end: 0; }
122
130
 
123
- /* A column of numbers reads right-aligned against its own heading. */
124
- .table .numeric { text-align: right; padding-inline: var(--space-3) 0; }
131
+ /* A column of numbers reads right-aligned against its own heading. The
132
+ space moves to the start, where the previous column's text now ends —
133
+ but only the start: written as the padding-inline shorthand this also set
134
+ the end to zero on every numeric cell, which is right for one in the
135
+ final column, already covered by the rule above, and wrong anywhere else,
136
+ because the next column then begins exactly where the number ends. */
137
+ .table .numeric { text-align: right; padding-inline-start: var(--space-3); }
125
138
 
126
139
  /* --- Definition list ----------------------------------------------------
127
140
  Term and value in two columns, the term set in the quiet register and the
@@ -193,9 +206,15 @@
193
206
  font: inherit;
194
207
  /* No padding above it: the rule belongs under the text, not a line's
195
208
  height below it, or the label reads as belonging to nothing. */
196
- padding-block: 0 var(--space-1);
209
+ padding-block: 0 calc(var(--space-1) - var(--rule-hair));
197
210
  }
198
211
 
212
+ /* A select takes its height from the browser rather than from the leading
213
+ it was given — `font: inherit` hands it the family and the size and puts
214
+ the line box back to normal — so it is the one control told what it is:
215
+ the same four baselines the others come out at. */
216
+ .field select { height: var(--space-4); }
217
+
199
218
  .field textarea { resize: vertical; }
200
219
 
201
220
  /* A checkbox or a radio is the one control whose label sits beside it. */
@@ -233,7 +252,8 @@
233
252
  background: transparent;
234
253
  color: var(--ink);
235
254
  font-family: inherit;
236
- padding: var(--space-1) var(--space-2);
255
+ padding-block: calc(var(--space-1) - var(--rule-hair));
256
+ padding-inline: var(--space-2);
237
257
  cursor: pointer;
238
258
  text-decoration: none;
239
259
  }
@@ -329,7 +349,7 @@
329
349
  align-items: baseline;
330
350
  gap: var(--space-1) var(--space-2);
331
351
  margin-top: var(--space-5);
332
- padding-top: var(--space-2);
352
+ padding-top: calc(var(--space-2) - var(--rule-hair));
333
353
  border-top: var(--rule-hair) solid var(--rule);
334
354
  }
335
355
 
@@ -356,7 +376,7 @@
356
376
 
357
377
  .errors {
358
378
  width: 100%;
359
- padding-top: var(--space-2);
379
+ padding-top: calc(var(--space-2) - var(--rule-heavy));
360
380
  border-top: var(--rule-heavy) solid var(--accent);
361
381
  }
362
382
 
@@ -393,7 +413,8 @@
393
413
  left: var(--space-2);
394
414
  top: var(--space-2);
395
415
  z-index: 1;
396
- padding: var(--space-1) var(--space-2);
416
+ padding-block: calc(var(--space-1) - var(--rule-hair));
417
+ padding-inline: var(--space-2);
397
418
  background: var(--paper);
398
419
  border: var(--rule-hair) solid var(--ink);
399
420
  transform: translateY(calc(-100% - var(--space-4)));
@@ -28,8 +28,16 @@
28
28
  img, svg, video { max-width: 100%; display: block; }
29
29
 
30
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; }
31
+ spacing ladder should be setting rather than the browser — hence the zero
32
+ spacing.
33
+
34
+ Separate rather than collapsed, because a collapsed border belongs to the
35
+ boundary rather than to either cell, and the browser pays for it by
36
+ splitting it: every row lands on a half pixel and the first one is half a
37
+ pixel short. Nothing here draws a vertical rule or a border two cells
38
+ could share, so separate borders cost nothing and each row is exactly as
39
+ tall as the ladder says. */
40
+ table { border-collapse: separate; border-spacing: 0; }
33
41
 
34
42
  /* The browser hides [hidden] with display: none, and any author rule that
35
43
  sets display at all outranks it — a .button says inline-block, so a
@@ -22,6 +22,12 @@
22
22
  box-shadow: inset 0 0 0 var(--rule-hair) color-mix(in oklch, var(--ink) 12%, transparent);
23
23
  }
24
24
 
25
+ /* A swatch is not a line of type. The type registers carry a padding that
26
+ rounds their cap height up to a baseline; a dd whose whole content is a
27
+ block of value has no cap to correct, and the padding would just make the
28
+ row four pixels taller than the ladder says. */
29
+ .pairs dd:has(> .specimen__value) { text-box: normal; padding-block-start: 0; }
30
+
25
31
  /* The field, drawn. Each child is one span, and the bar inside it shows
26
32
  where that span starts and stops against the page. */
27
33
  .specimen__grid { row-gap: var(--space-1); }
@@ -63,10 +63,28 @@
63
63
 
64
64
  /* --- Rules -------------------------------------------------------------
65
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. */
66
+ kept for the two or three places a page has something to insist on.
67
+
68
+ A rule is drawn inside the box it belongs to, never on top of it:
69
+ wherever one sits on a padded edge, its width comes out of that
70
+ padding — calc(var(--space-1) - var(--rule-hair)) — and wherever it is
71
+ the element, out of the margin above it. A pixel added instead of
72
+ taken is a box a pixel taller than the ladder says, which puts
73
+ everything below it off the baseline; and since every rule on the page
74
+ does it, the error accumulates down the column rather than showing up
75
+ once. */
67
76
  --rule-hair: 1px;
68
77
  --rule-heavy: 2px;
69
78
 
79
+ /* What a trimmed block adds above its cap to reach the next baseline.
80
+ Zero here and given a value in type.css where the browser can trim, so
81
+ a component that sets its own padding writes the same arithmetic in
82
+ both directions — calc(var(--space-1) + var(--cap-correction)) is the
83
+ padding either way. It is deliberately not resolved here: a custom
84
+ property is substituted where it is used, so 1cap inside it is the cap
85
+ height of the element that reads it rather than of the root. */
86
+ --cap-correction: 0px;
87
+
70
88
  /* --- Type --------------------------------------------------------------
71
89
  Five sizes on an alternating 1.33 / 1.5 ratio, every one a multiple of
72
90
  four. Five is the count an interface actually uses: labels, body, a
@@ -60,6 +60,7 @@
60
60
  At a 16px line box these are 14px targets, under the 24px minimum. The
61
61
  leading does the work so the type does not have to change. */
62
62
  .micro--tap,
63
+ .choice label,
63
64
  .pagination a,
64
65
  .pagination [aria-current] { line-height: var(--space-3); }
65
66
 
@@ -106,4 +107,66 @@
106
107
  .copy { font: inherit; }
107
108
 
108
109
  code, pre, kbd, samp { font-family: var(--font-mono); font-size: 0.9em; }
110
+
111
+ /* --- The baseline, actually -------------------------------------------
112
+ Everything above puts whole baselines between one line box and the next,
113
+ which keeps blocks in step but is not yet a baseline grid: where a line's
114
+ baseline falls inside its line box depends on the font's ascent and the
115
+ leading either side of it, so a caption's baselines and a paragraph's are
116
+ both 24 apart and 3px out of register with each other. The grid was the
117
+ boxes' and the type sat wherever the font left it.
118
+
119
+ Trimming makes the box the type's: the block's over edge becomes the cap
120
+ of its first line and its under edge the baseline of its last. The cap
121
+ height is then all that separates the box from the grid, and one padding
122
+ rounds it up to a whole baseline — measured by the browser, in cap units,
123
+ so a library that leaves the family to the application still never has to
124
+ be told the font's metrics. Every baseline in the block lands on a line,
125
+ because the rest are a whole number of them above the last.
126
+
127
+ Behind @supports, and the fallback is the rhythm above: Chromium and
128
+ Safari trim, Firefox does not yet, and a page that keeps its boxes on the
129
+ ladder is what the two have in common. */
130
+ @supports (text-box: trim-both cap alphabetic) and (padding-top: round(up, 1cap, 8px)) {
131
+ :root { --cap-correction: calc(round(up, 1cap, var(--baseline)) - 1cap); }
132
+
133
+ .page-title,
134
+ h1, h2, h3, h4,
135
+ p, li, dt, dd, th, td, blockquote, figcaption, caption, legend, label,
136
+ .micro, .hint, .lede, .empty,
137
+ /* Flex items are blockified, so the text in a row is trimmable too — and
138
+ has to be, or a row that aligns on the baseline is aligning a trimmed
139
+ box against an untrimmed one. */
140
+ .nav a,
141
+ .pagination a,
142
+ .pagination [aria-current],
143
+ .pagination__gap {
144
+ text-box: trim-both cap alphabetic;
145
+ padding-block-start: var(--cap-correction);
146
+ }
147
+
148
+ /* A control's box is the target, not the type in it: a button is five
149
+ baselines of box with its label centred, and trimming the label would
150
+ take the box with it. */
151
+ .button,
152
+ .copy,
153
+ /* The label beside a checkbox is the exception among labels: it shares a
154
+ row with a control the browser draws at a size of its own, and the row
155
+ is held to a line by the leading rather than by the box. */
156
+ .choice label { text-box: normal; padding-block-start: 0; }
157
+ }
158
+
159
+ /* A token set in a line of prose is a second font on that line, and a
160
+ second font brings its own ascent and descent. Both inline boxes align on
161
+ the baseline and the line box has to hold both, so the line grows — by a
162
+ pixel at body size, by four under the page title, and only on the lines
163
+ that happen to mention a token. Zero leading takes the inline box out of
164
+ that calculation and leaves the line to the strut, which is the one thing
165
+ on the line already measured in baselines. The glyphs are unaffected:
166
+ leading has never been what draws them.
167
+
168
+ Inline only. A pre is a block, and a block with no leading has no lines. */
169
+ code, kbd, samp { line-height: 0; }
170
+
171
+ pre { line-height: var(--space-3); }
109
172
  }
@@ -1,19 +1,18 @@
1
+ <%# locals: (page:, pages:, url:, window: 2, label: "Pages") %>
1
2
  <%#
2
- A run of numbers under a hairline. Takes a page number, a total, and a block
3
- that turns a number into a URL — the library has no paginator and no opinion
4
- about which one an application uses.
3
+ A run of numbers under a hairline. Takes a page number, a total, and a
4
+ callable that turns a number into a URL — the library has no paginator and
5
+ no opinion about which one an application uses. Long runs are elided around
6
+ the current page, because a hundred numbers is not a control.
5
7
 
6
- <%= render "its_swiss/shared/pagination", page: @page, pages: @pages,
7
- url: ->(n) { colors_path(page: n) } %>
8
-
9
- Long runs are elided around the current page, because a hundred numbers is
10
- not a control.
8
+ The worked example is in the README, and has to be: an ERB comment ends at
9
+ the first closing delimiter it meets, including one belonging to an example
10
+ written inside it, and every line after that is emitted as page content.
11
+ Which is what this file did, on the specimen, for the whole of 0.1.0.
11
12
  %>
12
- <% window = local_assigns.fetch(:window, 2) %>
13
- <% numbers = its_swiss_page_numbers(page, pages, window:) %>
14
13
  <% if pages > 1 %>
15
- <nav class="pagination" aria-label="<%= local_assigns.fetch(:label, "Pages") %>">
16
- <% numbers.each do |number| %>
14
+ <nav class="pagination" aria-label="<%= label %>">
15
+ <% its_swiss_page_numbers(page, pages, window:).each do |number| %>
17
16
  <% if number.nil? %>
18
17
  <span class="pagination__gap" aria-hidden="true">…</span>
19
18
  <% elsif number == page %>
@@ -3,13 +3,21 @@
3
3
  <table class="table">
4
4
  <caption>Ruled horizontally, never vertically. No zebra: a filled row is a filled area.</caption>
5
5
  <thead>
6
- <tr><th scope="col">Token</th><th scope="col">Step</th><th scope="col" class="numeric">Lightness</th></tr>
6
+ <tr>
7
+ <th scope="col">Token</th>
8
+ <th scope="col" class="numeric">Lightness</th>
9
+ <th scope="col">For</th>
10
+ </tr>
7
11
  </thead>
8
12
  <tbody>
9
- <tr><td>Paper</td><td><code>--value-0</code></td><td class="numeric">98%</td></tr>
10
- <tr><td>Hairline</td><td><code>--value-2</code></td><td class="numeric">89%</td></tr>
11
- <tr><td>Quiet ink</td><td><code>--value-4</code></td><td class="numeric">54%</td></tr>
12
- <tr><td>Ink</td><td><code>--value-5</code></td><td class="numeric">18%</td></tr>
13
+ <tr><td>Paper</td><td class="numeric">98%</td><td>The ground</td></tr>
14
+ <tr><td>Hairline</td><td class="numeric">89%</td><td>A rule you barely see</td></tr>
15
+ <tr><td>Quiet ink</td><td class="numeric">54%</td><td>Secondary text</td></tr>
16
+ <tr><td>Ink</td><td class="numeric">18%</td><td>Ink</td></tr>
13
17
  </tbody>
14
18
  </table>
19
+ <p class="hint">
20
+ The numeric column is deliberately not the last one: a figure that runs
21
+ straight into the next column is what this table shipped with.
22
+ </p>
15
23
  </section>
@@ -29,10 +29,15 @@
29
29
  <%= csrf_meta_tags %>
30
30
  <%= csp_meta_tag %>
31
31
 
32
- <%= yield :head %>
33
-
34
32
  <%= its_swiss_stylesheet_tags %>
35
33
  <%= javascript_importmap_tags if respond_to?(:javascript_importmap_tags) %>
34
+
35
+ <%# After the library, not before it. A layer takes its place in the order
36
+ from where it is first named, so a page that adds a layered stylesheet
37
+ through this slot — the specimen's own furniture is one — would
38
+ otherwise land ahead of every layer the library declares and lose to
39
+ all of them. %>
40
+ <%= yield :head %>
36
41
  </head>
37
42
 
38
43
  <body>
@@ -16,15 +16,20 @@ module ItsSwiss
16
16
  template "theme.css", "app/assets/stylesheets/theme.css"
17
17
  end
18
18
 
19
- # An application that has already chosen a layout has chosen it.
20
- def render_through_the_shell
21
- controller = "app/controllers/application_controller.rb"
22
- return say_status(:skip, "#{controller} not found", :yellow) unless exists?(controller)
23
-
24
- contents = read(controller)
25
- return say_status(:skip, "#{controller} already chooses a layout", :yellow) if contents.match?(/^\s*layout\s/)
26
-
27
- inject_into_class controller, "ApplicationController", %( layout "its_swiss/shell"\n)
19
+ # A nested layout, not a `layout` line on the controller.
20
+ #
21
+ # 0.1.0 injected `layout "its_swiss/shell"` into ApplicationController.
22
+ # That renders the shell, but the shell is filled through content_for,
23
+ # and content_for has to run while a view is rendering — so there was
24
+ # nowhere to set the masthead once, and an application would write it
25
+ # into every view before noticing. This is the Rails idiom for the same
26
+ # thing, and it needs no line on the controller at all, because
27
+ # application.html.erb is already the default layout's name.
28
+ #
29
+ # copy_file rather than template: the file is ERB the application will
30
+ # run, not ERB this generator should.
31
+ def create_the_layout
32
+ copy_file "application.html.erb", "app/views/layouts/application.html.erb"
28
33
  end
29
34
 
30
35
  # Development only, and the controller refuses it a second time: the
@@ -50,8 +55,9 @@ module ItsSwiss
50
55
 
51
56
  its-swiss is installed. What is left is yours:
52
57
 
53
- app/assets/stylesheets/theme.css the accent, the typeface, the grid
54
- /its-swiss/specimen every component, rendered twice
58
+ app/assets/stylesheets/theme.css the accent, the typeface, the grid
59
+ app/views/layouts/application.html.erb the shell's slots, to fill
60
+ /its-swiss/specimen every component, rendered twice
55
61
 
56
62
  The library ships no typeface and no palette. Set --accent to one
57
63
  colour and read the specimen with it unset: if the page still works,
@@ -0,0 +1,32 @@
1
+ <%# What this application puts in the slots its_swiss/shell leaves. Everything
2
+ else a layout used to have to remember — the view transition opt-in, the
3
+ library's stylesheets, the importmap, a way past the masthead for a
4
+ keyboard, and the flash — is the shell's.
5
+
6
+ A layout rather than `layout "its_swiss/shell"` on the controller, because
7
+ content_for has to run while a view is rendering and these are set once for
8
+ the whole application. The last line is what makes this a layout *for* the
9
+ shell rather than one instead of it. %>
10
+
11
+ <% content_for :head do %>
12
+ <%# This application's own stylesheets. The shell links the library's six and
13
+ stops; theme.css holds the accent and the grid, and nothing links it but
14
+ this. Unlayered, so they win wherever the two meet, whatever order they
15
+ arrive in. %>
16
+ <%= stylesheet_link_tag "theme", "data-turbo-track": "reload" %>
17
+ <% end %>
18
+
19
+ <% content_for :mark do %>
20
+ <%= link_to Rails.application.class.module_parent_name.underscore.humanize, root_path %>
21
+ <% end %>
22
+
23
+ <% content_for :nav do %>
24
+ <%# nav_link_to marks where you are with aria-current, which the library
25
+ colours and weights. Pass current: where current_page? would be wrong —
26
+ on a show page that still belongs to an index, say. %>
27
+ <% end %>
28
+
29
+ <%# Give main the grid when this application's pages are laid out on one:
30
+ content_for :main_class, "grid" %>
31
+
32
+ <%= render template: "layouts/its_swiss/shell" %>
@@ -1,3 +1,3 @@
1
1
  module ItsSwiss
2
- VERSION = "0.1.0"
2
+ VERSION = "0.3.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: its-swiss
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bobby Meyer
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-31 00:00:00.000000000 Z
11
+ date: 2026-09-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: railties
@@ -136,6 +136,7 @@ files:
136
136
  - config/importmap.rb
137
137
  - config/routes.rb
138
138
  - lib/generators/its_swiss/install/install_generator.rb
139
+ - lib/generators/its_swiss/install/templates/application.html.erb
139
140
  - lib/generators/its_swiss/install/templates/theme.css
140
141
  - lib/its-swiss.rb
141
142
  - lib/its_swiss.rb