its-swiss 0.7.2 → 0.9.1

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: 7f884ebf4471bd9c1bdc232a35cf327949466b3db98748a6bb008f985314e091
4
- data.tar.gz: d8a1e3b75d21f52e96850a57adf3dcbc72242638f9f7d62fe71126bf8ffa5272
3
+ metadata.gz: 7ab6df97c9cfca52b616b05f5e985aa37df4253563fd1b1177c9c67890706cad
4
+ data.tar.gz: 6e9d6316b70b2670b7b307b33ec64955e88d562d085c95a86e07836e635d1a97
5
5
  SHA512:
6
- metadata.gz: 5157d8c0cd8ad7db0db4025199d76ce4888ed2ad85d4ffb69a4427c97539482f646f56fe1fb274c831cf9f4d4a99a02185e9eb230cbc6516294bb805f631f815
7
- data.tar.gz: 7a2ca8413c7cfdaa998a033bdeb9cae176cf7487cdab8af8162b784d01006f264c835a092a11f48bff1c549459dd61166259b6ca9f73771cd3241d0bbfc1430c
6
+ metadata.gz: 5a3f8671a25c996f2883add93ead145a89244a8a5bbec670022c0b3d49d587d19dd1b5a664eb94bd49908b1d8a4b28877e66b180ac49d3b653b47b0493cf7d02
7
+ data.tar.gz: 286de25b2203d9928f9a4cbf596341aa8d450c3cfaecf016cafff44737586df4d4cd65d98271d102f2dcf2af9e8f9e058413ead233ad86602a3e242a5f471a4d
data/CHANGELOG.md CHANGED
@@ -2,6 +2,101 @@
2
2
 
3
3
  Semver. Consumers pin `~> 0.1`.
4
4
 
5
+ ## 0.9.1 — 2026-09-07
6
+
7
+ ### Fixed
8
+
9
+ - **The suite, and so the release, under json 3.0.0.** json 3.0.0 came out
10
+ twenty minutes after 0.9.0's pull request went green and changed the
11
+ signature of `JSON.parse`; Active Support 8.1.3.1 still calls it the old
12
+ way when it reads a signed cookie, so the session and the flash raised on
13
+ every request and the release of 0.9.0 stopped at its own tests. The
14
+ Gemfile holds json below 3 until a Rails that takes it. Nothing in the
15
+ library changed; 0.9.1 is 0.9.0 released.
16
+
17
+ ## 0.9.0 — 2026-09-07
18
+
19
+ A day of use, and what a day asks of a style: less talk and one red.
20
+
21
+ ### Added
22
+
23
+ - **Sections.** `page_head(title, sections:)` names the surfaces of one
24
+ page under its title — what a thing is made of, what it wears, how it
25
+ leaves — the one shown in the weight, and a page shows that one alone.
26
+ A working page must not scroll: what is done daily is reached from the
27
+ head, in the same position on every page, never by scrolling past what
28
+ was done yesterday. `page_sections(sections)` writes the nav on its own.
29
+
30
+ - **Explanations.** `explain(text)` puts what a section means behind one
31
+ mark: a question mark on the line, closed, and the text in the hint
32
+ register under it when it is asked for. A sentence over every table is
33
+ needed on the first day and never again.
34
+
35
+ ### Changed
36
+
37
+ - **One red per page.** The chosen filter and a current menu are in the
38
+ weight, in ink. The accent is for where you are on the site — the nav,
39
+ the subnav, the page numbers — and for the one thing that cannot be
40
+ undone. A page that said "this one" in red five times above the fold
41
+ had no signal left.
42
+
43
+ - **Hints are prose.** `.hint` is set at body size. The small size is for
44
+ labels and metadata, and a hint set in it at a body line's leading read
45
+ as a list of afterthoughts. Table headings go the other way: the label
46
+ register, bold, on the line.
47
+
48
+ - **The filter block is ruled once, below.** The rule above it was a third
49
+ rule in ten lines, and the rule is the one mark this style draws.
50
+
51
+ ## 0.8.0 — 2026-09-07
52
+
53
+ The third consumer, and what it and the second found in common.
54
+
55
+ ### Added
56
+
57
+ - **A page head.** `page_head(title, lede:) { actions }` writes the one
58
+ shape every page opens with: the title in the page-title register, its
59
+ lede when it has one, and the actions in a run beside them, wrapping
60
+ under the title when the page is narrow. Pandatone, Stripeclub and
61
+ Badger had each drawn their own, and no two agreed on the size of the
62
+ title or where the buttons went. The document is titled from it unless
63
+ the view has titled itself.
64
+
65
+ - **Filters.** A block of registers that narrow a list, each a quiet label
66
+ and then the choices, in the same two positions every time — a search, a
67
+ tag, an order, a size are the same kind of control, and are built the
68
+ same way. `filter_register(label, choices, name:)` writes one; the choice
69
+ in force carries `aria-current`, which the CSS colours and weights, for
70
+ the reason the nav says it twice. `search_form(url, frame:, keep:)`
71
+ writes the first register: a search that narrows the list as you type,
72
+ into the Turbo Frame named, carrying the other choices so a search does
73
+ not drop them. Two slots go with it: `--filter-label`, the column the
74
+ labels occupy, and `--card`.
75
+
76
+ - **Cards.** `.cards` on a `.grid` lays a list out on the page's own
77
+ fields, each item `--card` fields wide, two lines between the rows.
78
+ `.card__link`, `.card__figure`, `.card__name`, `.card__meta` are the
79
+ parts. No box: the picture is the only filled area a card has.
80
+
81
+ - **A menu in the nav, and a subnav under the masthead.** `nav_menu(label,
82
+ current:) { links }` is a destination that opens into destinations — a
83
+ details element, so it opens with no script — and carries the accent and
84
+ the weight when where you are is inside it. The shell renders `:subnav`,
85
+ when an application fills it, as a shaded band under the masthead: a
86
+ second layer of destinations, inside the one you are in. A host that
87
+ carries several tools puts the tools in the menu and each tool's own
88
+ sections in the band.
89
+
90
+ - **A live search controller.** `its_swiss/live_search_controller.js`,
91
+ pinned by the engine like the clipboard, submits a search form as you
92
+ type and takes away the button it makes redundant. The host registers it
93
+ as `its-swiss-live-search`, beside the clipboard.
94
+
95
+ ### Changed
96
+
97
+ - The micro register and the quiet colour take `.filter__label` and
98
+ `.card__meta`.
99
+
5
100
  ## 0.7.2 — 2026-09-04
6
101
 
7
102
  ### Changed
data/README.md CHANGED
@@ -34,7 +34,7 @@ current one at `/<version>.html` for anything that needs to pin.
34
34
  | In the gem | Stays in the application |
35
35
  | --- | --- |
36
36
  | Tokens, reset, base typography | The grid itself — which blocks span which fields |
37
- | Masthead, nav, footer, table, form, button, definition list, pagination | Domain components |
37
+ | Masthead, nav, page head, footer, table, form, button, definition list, pagination, filters, cards | Domain components |
38
38
  | A base layout shell with `content_for` slots | Page layouts beyond the shell |
39
39
  | View transition names and durations | Which pages transition to which |
40
40
  | The value scale and the accent slot | Any hue, any palette knowledge |
@@ -71,6 +71,8 @@ holding all of them. Nothing here has a default the gem could pick honestly.
71
71
  | `--columns`, `--gutter` | How many fields this problem has |
72
72
  | `--line` | The baseline: the interval everything vertical registers to |
73
73
  | `--ratio` | A picture's aspect ratio, per figure — the library cannot read one |
74
+ | `--card` | How many fields a card takes, and at which widths |
75
+ | `--filter-label` | The column a filter's label occupies, so every register's choices start on one line |
74
76
  | `--face-150`, `--face-200`, `--face-100` | Read, not set: the face a register names for its ratio of leading to size |
75
77
 
76
78
  ### The baseline
@@ -313,6 +315,83 @@ it stops on a field line:
313
315
  --measure: calc(var(--field) * 3 + var(--gutter) * 2);
314
316
  ```
315
317
 
318
+ ### The page head
319
+
320
+ What a page is and what can be done to it, in one shape on every page: the
321
+ title in the page-title register, its lede when it has one, and the actions in
322
+ a run beside them, wrapping under the title when the page is narrow.
323
+
324
+ ```erb
325
+ <%= page_head "Palettes", lede: "Every palette in the library." do %>
326
+ <%= link_to "New palette", new_palette_path, class: "button button--primary" %>
327
+ <% end %>
328
+ ```
329
+
330
+ The document is titled from it unless the view has already set `:title`.
331
+
332
+ A page with several surfaces — what a thing is made of, what it wears, how it
333
+ leaves — names them under the title with `sections:`, each a name, a URL and
334
+ whether it is the one shown, and shows that one alone. A working page must
335
+ not scroll: what is done daily is reached from the head, in the same position
336
+ on every page.
337
+
338
+ ```erb
339
+ <%= page_head @pattern.name, sections: [ [ "Compose", pattern_path(@pattern), @section == "compose" ],
340
+ [ "Dress", pattern_path(@pattern, section: "dress"), @section == "dress" ] ] do %>
341
+ ```
342
+
343
+ ### Explanations
344
+
345
+ What a section means, behind one mark. A sentence over every table is needed
346
+ on the first day and never again, and a tool used daily is read on every
347
+ other day; so `explain` writes it in the hint register, closed, and a
348
+ question mark on the line opens it.
349
+
350
+ ```erb
351
+ <%= explain "The repeat, in order along the stripe normal." %>
352
+ ```
353
+
354
+ ### Filters and cards
355
+
356
+ What narrows a list, and the list. A search that filters as you type, and a
357
+ register for each other way of narrowing it — a tag, an order, a size — in the
358
+ same two positions every time: a quiet label, then the choices, the one in
359
+ force carrying `aria-current`, which the CSS sets in the weight. In ink, not
360
+ the accent: the accent is for where you are on the site and for the one
361
+ thing that cannot be undone, and a page has one red.
362
+
363
+ ```erb
364
+ <div class="filters">
365
+ <%= search_form palettes_path, frame: "palettes", keep: { tag: params[:tag], sort: params[:sort] } %>
366
+ <%= filter_register "Tagged", [ [ "All", palettes_path, params[:tag].blank? ],
367
+ *tags.map { |t| [ t, palettes_path(tag: t), params[:tag] == t ] } ],
368
+ name: "tag" %>
369
+ </div>
370
+
371
+ <%= turbo_frame_tag "palettes", target: "_top" do %>
372
+ <ul class="grid cards">
373
+ <li class="card">
374
+ <a class="card__link" href="…">
375
+ <figure class="card__figure figure" style="--ratio: 1.5"><img src="…" alt=""></figure>
376
+ <span class="card__name">Brand Core</span>
377
+ </a>
378
+ <span class="card__meta">4 swatches</span>
379
+ </li>
380
+ </ul>
381
+ <% end %>
382
+ ```
383
+
384
+ The search form belongs outside the frame it fills, so only the results are
385
+ replaced and the field keeps its cursor. `keep:` carries the other choices as
386
+ hidden fields, so a search does not drop the tag or the order you were reading
387
+ in. The button is there for a browser that runs no script; the controller
388
+ takes it away once it has connected.
389
+
390
+ A card is `--card` fields wide on the page's own grid — say how many once, and
391
+ again at each width that changes it. The picture is a `.figure` with its
392
+ `--ratio` declared, so its box is whole lines at any width and the name under
393
+ it stays on the grid.
394
+
316
395
  ## The layout shell
317
396
 
318
397
  The installer writes `app/views/layouts/application.html.erb` as a layout
@@ -345,6 +424,7 @@ Slots, all optional:
345
424
  | `:head` | Anything else that belongs in `<head>` |
346
425
  | `:mark` | The wordmark. No mark and no nav means no masthead at all |
347
426
  | `:nav` | The destinations |
427
+ | `:subnav` | A second layer of destinations, inside the one you are in: a shaded band under the masthead |
348
428
  | `:main_class` | What the page's main region is, if it is a grid |
349
429
  | `:footer` | Whatever belongs after the page |
350
430
 
@@ -359,9 +439,15 @@ beyond the shell is the application's, for the same reason its grid is.
359
439
  | `its_swiss_stylesheet_tags` | The seven links, tracked for Turbo |
360
440
  | `its_swiss_typeface(regular:, bold:)` | The application's typeface, declared under the library's face names |
361
441
  | `nav_link_to(name, url, current:)` | A destination, with `aria-current` when you are at it |
442
+ | `nav_menu(label, current:) { links }` | A destination that opens into destinations, with no script |
362
443
  | `copy_button(value)` | A value that copies itself |
363
444
  | `its_swiss_form_with(...)` | `form_with`, already holding the library's builder |
364
445
  | `its_swiss_page_numbers(page, pages)` | Which numbers a run of them shows, elided |
446
+ | `page_head(title, lede:, sections:) { actions }` | The one shape every page opens with |
447
+ | `page_sections(sections)` | The surfaces of one page, the one shown in the weight |
448
+ | `explain(text) { }` | What a section means, behind one mark |
449
+ | `filter_register(label, choices, name:)` | One register of a filter block |
450
+ | `search_form(url, frame:, keep:)` | A search that narrows a list as you type |
365
451
 
366
452
  ### Pagination
367
453
 
@@ -376,6 +462,24 @@ a page, a total, and something that turns a number into a URL:
376
462
  Long runs are elided around the current page. `window:` (default 2) sets how
377
463
  many neighbours show; `label:` names the `<nav>` for a screen reader.
378
464
 
465
+ ### JavaScript
466
+
467
+ Two Stimulus controllers, pinned by the engine so an application that upgrades
468
+ the gem gets the new file without touching its importmap. They are outside
469
+ `controllers/`, so an application registers them by hand, once:
470
+
471
+ ```js
472
+ // app/javascript/controllers/index.js
473
+ import ItsSwissClipboardController from "its_swiss/clipboard_controller"
474
+ import ItsSwissLiveSearchController from "its_swiss/live_search_controller"
475
+ application.register("its-swiss-clipboard", ItsSwissClipboardController)
476
+ application.register("its-swiss-live-search", ItsSwissLiveSearchController)
477
+ ```
478
+
479
+ `copy_button` and `search_form` write the `data-controller` attributes; a page
480
+ without the registrations still works, with the value selectable and the
481
+ search's button on the page.
482
+
379
483
  ## The form builder
380
484
 
381
485
  One shape for every field: a label, a control, and — when there is something to
@@ -464,7 +568,8 @@ ladder; the names are the library's rather than the application's.
464
568
  | `.masthead__nav` | `.nav` |
465
569
  | `.channels` | `.pairs` |
466
570
  | `.form`, `.field`, `.button*`, `.copy`, `.errors`, `.hint`, `.empty` | unchanged |
467
- | `--columns-dense`, `--card`, `--card-wide`, `.swatch*`, `.tag*`, `.filter*` | stay in Pandatone |
571
+ | `.tag*`, `.filter*`, `.page-head`, `live-search` | `.filter*`, `.page-head`, `.cards`, `its-swiss-live-search` in the gem since 0.8.0 |
572
+ | `--columns-dense`, `--card-wide`, `.swatch*` | stay in Pandatone |
468
573
 
469
574
  Two behavioural differences to know about:
470
575
 
@@ -0,0 +1,31 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Submits a search form as you type, into the Turbo Frame it targets.
4
+ //
5
+ // The filtering stays on the server, in the same scope the button uses: one
6
+ // definition of what a search matches, not a Ruby one and a drifting
7
+ // JavaScript copy. The form sits outside its frame so only the results are
8
+ // replaced and the field keeps its focus and its cursor.
9
+ //
10
+ // The button goes when this connects, because from that moment it is a second
11
+ // way to do the thing that has just been done. It is hidden here rather than
12
+ // left out of the markup for the browser that never runs this: without the
13
+ // controller the button is the only way to search, so it has to be on the
14
+ // page for the page to work.
15
+ export default class extends Controller {
16
+ static targets = [ "submit" ]
17
+ static values = { delay: { type: Number, default: 200 } }
18
+
19
+ connect() {
20
+ if (this.hasSubmitTarget) this.submitTarget.hidden = true
21
+ }
22
+
23
+ search() {
24
+ clearTimeout(this.timeout)
25
+ this.timeout = setTimeout(() => this.element.requestSubmit(), this.delayValue)
26
+ }
27
+
28
+ disconnect() {
29
+ clearTimeout(this.timeout)
30
+ }
31
+ }
@@ -70,6 +70,92 @@
70
70
  font-weight: 700;
71
71
  }
72
72
 
73
+ /* A menu in the nav: a destination that opens into destinations. A
74
+ details element, so it opens and closes with no script, and a summary
75
+ set like the other destinations with a small mark that says it opens.
76
+ The list floats under it on paper with a keyline, the one floating box
77
+ in the style — a menu that pushed the page down would not be a menu. */
78
+ .menu {
79
+ position: relative;
80
+ }
81
+
82
+ .menu > summary {
83
+ list-style: none;
84
+ cursor: pointer;
85
+ white-space: nowrap;
86
+ }
87
+
88
+ .menu > summary::-webkit-details-marker { display: none; }
89
+
90
+ .menu > summary::after {
91
+ content: " \25BE";
92
+ color: var(--ink-quiet);
93
+ }
94
+
95
+ .menu > summary:hover,
96
+ .menu[open] > summary { color: var(--ink); }
97
+
98
+ /* Where you are, when where you are is inside the menu: the weight, in
99
+ ink. Not the accent — a page has one red, and the subnav under a menu
100
+ that is current already names the place in it. */
101
+ .menu--current > summary {
102
+ color: var(--ink);
103
+ font-weight: 700;
104
+ }
105
+
106
+ /* Out of the flow, but still on the grid: a line of padding above and
107
+ below, and the keyline drawn inside, so every link in it sits on a
108
+ line of the page under it. */
109
+ .menu__list {
110
+ position: absolute;
111
+ inset-inline-start: calc(-1 * var(--space-2));
112
+ top: 100%;
113
+ z-index: 1;
114
+ display: flex;
115
+ flex-direction: column;
116
+ min-width: max-content;
117
+ padding: var(--line) var(--space-2);
118
+ background: var(--paper);
119
+ box-shadow: inset 0 0 0 var(--rule-hair) var(--rule);
120
+ }
121
+
122
+ .menu__list a {
123
+ display: block;
124
+ text-decoration: none;
125
+ }
126
+
127
+ .menu__list a:hover { color: var(--ink); }
128
+
129
+ /* --- Subnav -------------------------------------------------------------
130
+ A second layer of destinations, inside the one you are in: what a tool
131
+ has beside what the application has. A shaded band under the masthead,
132
+ three lines tall the way the masthead is, with the same run of links
133
+ the nav has and the same marking of where you are. Rendered by the
134
+ shell when the application fills :subnav, and not at all otherwise. */
135
+ /* The rule is drawn inside the band rather than under it, so the band and
136
+ the page inside it are both three whole lines. */
137
+ .subnav {
138
+ background: var(--paper-shaded);
139
+ box-shadow: inset 0 calc(-1 * var(--rule-hair)) 0 var(--rule);
140
+ }
141
+
142
+ .subnav > .page {
143
+ display: flex;
144
+ flex-wrap: wrap;
145
+ align-items: end;
146
+ gap: var(--line) var(--space-3);
147
+ padding-block: var(--line);
148
+ }
149
+
150
+ .subnav a { text-decoration: none; }
151
+
152
+ .subnav a:hover { color: var(--ink); }
153
+
154
+ .subnav [aria-current="page"] {
155
+ color: var(--accent);
156
+ font-weight: 700;
157
+ }
158
+
73
159
  /* --- Footer -------------------------------------------------------------
74
160
  A hairline, the micro register, and whatever the application has to say
75
161
  after the page. It carries no links of its own: what belongs down there
@@ -89,6 +175,94 @@
89
175
  line-height: var(--line);
90
176
  }
91
177
 
178
+ /* --- Page head ----------------------------------------------------------
179
+ What a page is and what can be done to it. The title and, when it has
180
+ one, its lede on the left; the actions on the right, wrapping under
181
+ the title when the page is narrow. Everything after it starts two
182
+ lines down, which is the gap a section gets.
183
+
184
+ One shape for every page, so a reader arriving from another application
185
+ set in this style already knows where the name is and where the buttons
186
+ are. The title is the page-title register: a page has one, and it is
187
+ the largest thing on it. */
188
+
189
+ .page-head {
190
+ display: flex;
191
+ flex-wrap: wrap;
192
+ justify-content: space-between;
193
+ align-items: flex-start;
194
+ gap: var(--line) var(--space-3);
195
+ margin-block-end: var(--line-2);
196
+ }
197
+
198
+ /* The title takes the measure and the actions take what they need. A
199
+ basis rather than a share, so a long name wraps inside the measure
200
+ instead of pushing the actions off the line. */
201
+ .page-head__title {
202
+ flex: 1 1 var(--measure);
203
+ min-width: 0;
204
+ }
205
+
206
+ /* The actions shrink to their widest button and wrap inside it, rather
207
+ than holding their one-line width and pushing a phone's page sideways. */
208
+ .page-head__actions { flex: 0 1 auto; }
209
+
210
+ /* --- Sections -----------------------------------------------------------
211
+ One page, several surfaces: what a thing is made of, what it wears,
212
+ how it leaves. A run of names under the title, the one you are on in
213
+ the weight, and the surface below it the only thing on the page. A
214
+ working page must not scroll: what is done every day is reached from
215
+ the head, in the same position on every page, and never by scrolling
216
+ past what was done yesterday. */
217
+ .sections {
218
+ display: flex;
219
+ flex-wrap: wrap;
220
+ gap: var(--line) var(--space-3);
221
+ }
222
+
223
+ /* Its own row in the head, under the title and the actions. */
224
+ .page-head > .sections { flex-basis: 100%; }
225
+
226
+ .sections a {
227
+ display: inline-block;
228
+ color: var(--ink-quiet);
229
+ text-decoration: none;
230
+ }
231
+
232
+ .sections a:hover { color: var(--ink); }
233
+
234
+ /* Where you are, in the weight. Ink, not the accent: the accent is for
235
+ where you are on the site, and this is where you are on the page. */
236
+ .sections [aria-current] {
237
+ color: var(--ink);
238
+ font-weight: 700;
239
+ }
240
+
241
+ /* --- Explanations -------------------------------------------------------
242
+ What a section means, behind one mark, for the day it is first read.
243
+ A sentence over every table is needed on the first day and never
244
+ again, and a tool used daily is read on every other day. So the
245
+ explanation is there, in the hint register, and closed: a question
246
+ mark on the line, and the text under it when it is asked for. A
247
+ details element, so it opens with no script. */
248
+ /* A block as wide as its one character, so the line it sits on is whole
249
+ and the target is the mark and not the row. */
250
+ .explain > summary {
251
+ list-style: none;
252
+ display: block;
253
+ width: max-content;
254
+ cursor: pointer;
255
+ color: var(--ink-quiet);
256
+ font-weight: 700;
257
+ }
258
+
259
+ .explain > summary::-webkit-details-marker { display: none; }
260
+
261
+ .explain > summary:hover,
262
+ .explain[open] > summary { color: var(--ink); }
263
+
264
+ .explain__body { margin-block-start: var(--line); }
265
+
92
266
  /* --- Rules --------------------------------------------------------------
93
267
  The one horizontal mark in the style, at the two weights tokens.css
94
268
  declares. */
@@ -184,9 +358,15 @@
184
358
  vertical-align: top;
185
359
  }
186
360
 
361
+ /* A heading is a label: the label register, bold, on the line. Body size
362
+ made it a second row of values. */
187
363
  .table th {
364
+ font-family: var(--face-200), var(--font-family);
365
+ font-size: var(--size-1);
366
+ line-height: var(--line);
188
367
  color: var(--ink-quiet);
189
368
  font-weight: 700;
369
+ letter-spacing: 0.01em;
190
370
  }
191
371
 
192
372
  .table thead th { border-bottom-color: var(--ink); }
@@ -490,6 +670,141 @@
490
670
 
491
671
  .pagination__gap { color: var(--ink-quiet); }
492
672
 
673
+ /* --- Filters ------------------------------------------------------------
674
+ What narrows a list. One shape for every way of narrowing it — a
675
+ search, a tag, an order, a size — because they are the same kind of
676
+ control: pick one of a handful. A register is a quiet label and then
677
+ the choices, in the same two positions every time, so filtering and
678
+ ordering read as the registers they are rather than as two rows of
679
+ identical words. Ruled below, once: the block belongs to the list
680
+ under it, and the space above it is the page head's. A second rule
681
+ above it was a third rule in ten lines, and the rule is the one mark
682
+ this style draws, so it has to be rare. */
683
+
684
+ .filters {
685
+ display: flex;
686
+ flex-wrap: wrap;
687
+ align-items: flex-start;
688
+ gap: var(--line) var(--space-3);
689
+ /* The rule is drawn inside the box, so the block stays whole lines. */
690
+ padding-block-end: calc(var(--line) - var(--rule-hair));
691
+ border-block-end: var(--rule-hair) solid var(--rule);
692
+ margin-block-end: var(--line);
693
+ }
694
+
695
+ .filter {
696
+ display: flex;
697
+ flex-wrap: wrap;
698
+ align-items: flex-start;
699
+ flex-basis: 100%;
700
+ gap: var(--line) var(--space-2);
701
+ }
702
+
703
+ /* The label column. A width off the ladder, so every register's choices
704
+ start on the same line down the page rather than a few pixels apart
705
+ because one word is longer than another. An application with longer
706
+ labels sets --filter-label once. */
707
+ .filter__label,
708
+ .filters .form--inline label { min-width: var(--filter-label); }
709
+
710
+ .filter__choices {
711
+ display: flex;
712
+ flex-wrap: wrap;
713
+ gap: var(--line) var(--space-2);
714
+ }
715
+
716
+ /* The choices are the flex items themselves, the way the nav's
717
+ destinations are: blockified, so each is a line box that is the tap
718
+ target and can be trimmed where the faces are not honoured. A list
719
+ item between them would be a second box to correct. */
720
+ .filter__choices a {
721
+ color: var(--ink-quiet);
722
+ text-decoration: none;
723
+ }
724
+
725
+ .filter__choices a:hover { color: var(--ink); }
726
+
727
+ /* Which one is chosen: the weight, in ink. The accent is for where you
728
+ are on the site and for the one thing that cannot be undone; a page
729
+ with four registers each saying "this one" in red has no signal left. */
730
+ .filter__choices [aria-current] {
731
+ color: var(--ink);
732
+ font-weight: 700;
733
+ }
734
+
735
+ /* The search is the first register, built like the others rather than as
736
+ a stacked field above them: the label in the label column, the control
737
+ where the choices start. Label and control meet on the under edge — the
738
+ label's baseline is its under edge, and the control's text stands on
739
+ the rule that closes it — so the two read as one line. The button is
740
+ there for a browser that runs no script and wraps under the field at a
741
+ phone's width; it goes when the live search connects, because from then
742
+ on typing has already done what it does. */
743
+ .filters .form--inline {
744
+ flex: 1 1 var(--measure);
745
+ max-width: var(--measure);
746
+ }
747
+
748
+ .filters .form--inline .field--inline {
749
+ flex: 1 1 auto;
750
+ align-items: flex-end;
751
+ min-width: 0;
752
+ }
753
+
754
+ /* A basis of nothing, so the control takes what the row has left rather
755
+ than the twenty characters a browser gives a text control by default. */
756
+ .filters .form--inline .field--inline input {
757
+ flex: 1 1 0;
758
+ width: auto;
759
+ min-width: 0;
760
+ }
761
+
762
+ /* --- Cards --------------------------------------------------------------
763
+ A list of things, each a picture over its name and a line about it,
764
+ laid out on the page's own fields: a card is --card fields wide, and
765
+ the application says how many, once, at each width it cares about. Two
766
+ lines between the rows, because a row of cards is a row of sections.
767
+
768
+ No box. A card is the thing itself, and the picture is the only filled
769
+ area it has; what separates one from the next is the gutter. */
770
+
771
+ .cards { row-gap: var(--line-2); }
772
+
773
+ .cards > * {
774
+ --span: var(--card);
775
+ min-width: 0;
776
+ }
777
+
778
+ /* The picture and the name are one link, so a card costs one tab stop and
779
+ announces its destination once. */
780
+ .card__link {
781
+ display: block;
782
+ color: inherit;
783
+ text-decoration: none;
784
+ }
785
+
786
+ .card__link:hover .card__name {
787
+ text-decoration: underline;
788
+ text-underline-offset: 0.2em;
789
+ }
790
+
791
+ /* The picture is a .figure with its --ratio declared, so its box is a
792
+ whole number of lines at any width and the name under it stays on the
793
+ grid; what this adds is the line between them. */
794
+ .card__figure { margin-block-end: var(--line); }
795
+
796
+ /* Blocks, so each is a line of its own and can be trimmed where the faces
797
+ are not honoured. */
798
+ .card__name,
799
+ .card__meta { display: block; }
800
+
801
+ .card__name {
802
+ font-family: var(--face-150), var(--font-family);
803
+ font-size: var(--size-2);
804
+ line-height: var(--line);
805
+ font-weight: 700;
806
+ }
807
+
493
808
  /* --- What a page has to say --------------------------------------------
494
809
  Three registers: it worked, it was refused, there is nothing here. The
495
810
  refusal is the only one that interrupts, and it interrupts with a rule
@@ -181,6 +181,14 @@
181
181
  );
182
182
  --measure: calc(var(--field) * 3 + var(--gutter) * 2);
183
183
 
184
+ /* --- Lists -------------------------------------------------------------
185
+ Two more slots an index fills. How many fields a card takes, which is
186
+ the application's to say and to change at each width it cares about;
187
+ and the column a filter's label occupies, so the choices of every
188
+ register start on one line down the page. */
189
+ --card: 1;
190
+ --filter-label: var(--space-8);
191
+
184
192
  /* --- Transitions -------------------------------------------------------
185
193
  The page crossfade is short enough to read as a replacement rather than
186
194
  an animation. A morph runs longer, because the thing travelling has to
@@ -81,7 +81,9 @@
81
81
  caption,
82
82
  legend,
83
83
  label,
84
- .field label {
84
+ .field label,
85
+ .filter__label,
86
+ .card__meta {
85
87
  font-family: var(--face-200), var(--font-family);
86
88
  font-size: var(--size-1);
87
89
  line-height: var(--line);
@@ -93,16 +95,23 @@
93
95
  picking a gray. */
94
96
  .quiet,
95
97
  .hint,
98
+ .filter__label,
99
+ .card__meta,
96
100
  .nav a,
101
+ .menu > summary,
102
+ .subnav a,
97
103
  .footer,
98
104
  .pairs dt,
99
105
  .button--quiet,
100
106
  .button--danger,
101
107
  .link-quiet { color: var(--ink-quiet); }
102
108
 
109
+ /* A hint is prose — a sentence about the thing beside it — and prose is
110
+ read at body size. The small size is for labels and metadata, and a
111
+ hint set in it read as a list of afterthoughts. */
103
112
  .hint {
104
- font-family: var(--face-200), var(--font-family);
105
- font-size: var(--size-1);
113
+ font-family: var(--face-150), var(--font-family);
114
+ font-size: var(--size-2);
106
115
  line-height: var(--line);
107
116
  max-width: var(--measure);
108
117
  }
@@ -119,6 +128,8 @@
119
128
  inherited, because a button does not inherit a font and a nav may sit
120
129
  inside a register that is not the body's. */
121
130
  .nav a,
131
+ .menu > summary,
132
+ .subnav a,
122
133
  .button {
123
134
  font-family: var(--face-150), var(--font-family);
124
135
  font-size: var(--size-2);
@@ -220,11 +231,18 @@
220
231
  .page-title,
221
232
  h1, h2, h3, h4,
222
233
  p, dt, th, td, figcaption, caption, legend, label,
223
- li:not(:has(> :is(p, ul, ol, dl, div, figure, table, pre, blockquote))),
234
+ li:not(:has(> :is(p, ul, ol, dl, div, figure, table, pre, blockquote, .card__link))),
224
235
  dd:not(:has(> :is(p, ul, ol, dl, div, figure, table, pre, blockquote))),
225
236
  .micro, .hint, .lede, .empty, .masthead__mark, .field__error, .errors__title,
237
+ .filter__label, .card__name, .card__meta,
238
+ .explain > summary,
226
239
  /* Flex items are blockified, so the text in a row is trimmable too. */
227
240
  .nav a,
241
+ .sections a,
242
+ .menu > summary,
243
+ .menu__list a,
244
+ .subnav a,
245
+ .filter__choices a,
228
246
  .pagination a,
229
247
  .pagination [aria-current],
230
248
  .pagination__gap,
@@ -80,6 +80,21 @@ module ItsSwiss
80
80
  link_to(name, url, **options, &block)
81
81
  end
82
82
 
83
+ # A destination in the nav that opens into destinations: a details
84
+ # element with the label as its summary and the block as its list, so it
85
+ # opens and closes with no script. current: when one of the destinations
86
+ # inside is where you are, the summary carries the accent and the weight
87
+ # the way a current link does.
88
+ #
89
+ # <%= nav_menu "Tools", current: in_a_tool? do %>
90
+ # <%= nav_link_to "Pandatone", "/pandatone", current: in_engine?(pandatone) %>
91
+ # <% end %>
92
+ def nav_menu(label, current: false, &block)
93
+ tag.details(class: token_list("menu", ("menu--current" if current))) do
94
+ safe_join([ tag.summary(label), tag.div(capture(&block), class: "menu__list") ])
95
+ end
96
+ end
97
+
83
98
  # A value on screen exists to be taken somewhere else, so it is a button
84
99
  # that copies itself. The value stays visible text inside it, which is
85
100
  # what keeps it usable when the clipboard is not available at all.
@@ -133,6 +148,105 @@ module ItsSwiss
133
148
  end
134
149
  private :its_swiss_face
135
150
 
151
+ # What a page is and what can be done to it: the title, a lede when the
152
+ # page has one, and the actions in the block. One shape for every page,
153
+ # and the document is titled from it unless the view has titled itself.
154
+ #
155
+ # <%= page_head "Palettes", lede: "Every palette in the library." do %>
156
+ # <%= link_to "New palette", new_palette_path, class: "button button--primary" %>
157
+ # <% end %>
158
+ #
159
+ # A page with several surfaces names them under the title, and the one
160
+ # you are on carries the weight:
161
+ #
162
+ # <%= page_head @pattern.name, sections: [ [ "Compose", pattern_path(@pattern), true ],
163
+ # [ "Dress", pattern_path(@pattern, section: "dress"), false ] ] do %>
164
+ def page_head(title, lede: nil, sections: nil, &block)
165
+ content_for(:title, strip_tags(title.to_s)) unless content_for?(:title)
166
+ actions = capture(&block) if block
167
+
168
+ tag.header(class: "page-head") do
169
+ safe_join([
170
+ tag.div(class: "page-head__title") do
171
+ safe_join([ tag.h1(title, class: "page-title"), (tag.p(lede, class: "lede") if lede) ].compact)
172
+ end,
173
+ (tag.div(actions, class: "page-head__actions run") if actions.present?),
174
+ (page_sections(sections) if sections.present?)
175
+ ].compact)
176
+ end
177
+ end
178
+
179
+ # The surfaces of one page: a name, a URL and whether it is the one
180
+ # shown, each a whole navigation so it survives a reload and can be
181
+ # sent to somebody. The page head places it; on its own it is a nav.
182
+ def page_sections(sections, label: "Sections")
183
+ tag.nav(class: "sections", aria: { label: label }) do
184
+ safe_join(sections.map { |text, url, current| link_to(text, url, aria: { current: ("page" if current) }) })
185
+ end
186
+ end
187
+
188
+ # What a section means, behind one mark. The explanation is written in
189
+ # the hint register and closed: a page used daily is read on every day
190
+ # but the first, and a sentence over every table is for the first.
191
+ #
192
+ # <%= explain "The repeat, in order along the stripe normal." %>
193
+ # <%= explain do %><p class="hint">…</p><% end %>
194
+ def explain(text = nil, label: "Explain", &block)
195
+ body = block ? capture(&block) : tag.p(text, class: "hint")
196
+
197
+ tag.details(class: "explain") do
198
+ safe_join([ tag.summary("?", aria: { label: label }), tag.div(body, class: "explain__body") ])
199
+ end
200
+ end
201
+
202
+ # One register of a filter block: a quiet label, then the choices, each
203
+ # a name, a URL and whether it is the one in force. Filtering and
204
+ # ordering are the same kind of control — pick one of a handful — so
205
+ # they are built the same way and told apart by their label.
206
+ #
207
+ # <%= filter_register "Tagged", [ [ "All", colors_path, params[:tag].blank? ],
208
+ # *tags.map { |t| [ t, colors_path(tag: t), params[:tag] == t ] } ],
209
+ # name: "tag" %>
210
+ #
211
+ # The one in force carries aria-current, which the CSS colours and
212
+ # weights, and a test can find without reading a class.
213
+ def filter_register(label, choices, name: nil)
214
+ tag.div(class: "filter", data: { filter: name }) do
215
+ safe_join([
216
+ tag.span(label, class: "filter__label"),
217
+ tag.div(class: "filter__choices") do
218
+ safe_join(choices.map { |text, url, current| link_to(text, url, aria: { current: ("true" if current) }) })
219
+ end
220
+ ])
221
+ end
222
+ end
223
+
224
+ # A search that narrows a list as you type, into the Turbo Frame named.
225
+ # The form belongs outside that frame, so only the results are replaced
226
+ # and the field keeps its cursor; the button is for a browser that runs
227
+ # no script, and the controller takes it away once it has connected.
228
+ #
229
+ # <%= search_form palettes_path, frame: "palettes", keep: { tag: params[:tag], sort: params[:sort] } %>
230
+ #
231
+ # keep: the other choices on the page, carried as hidden fields so a
232
+ # search does not drop the tag or the order you were reading in. The
233
+ # host registers the controller as its-swiss-live-search.
234
+ def search_form(url, frame:, param: :q, label: "Search", value: nil, keep: {}, delay: nil, **options)
235
+ value = params[param] if value.nil? && respond_to?(:params)
236
+ data = { controller: "its-swiss-live-search", action: "input->its-swiss-live-search#search",
237
+ turbo_frame: frame, its_swiss_live_search_delay_value: delay }.compact
238
+
239
+ form_with(url: url, method: :get, class: token_list("form form--inline", options[:class]), data: data) do
240
+ safe_join([
241
+ *keep.compact_blank.map { |name, kept| hidden_field_tag(name, kept, id: nil) },
242
+ tag.div(class: "field field--inline") do
243
+ safe_join([ label_tag(param, label), search_field_tag(param, value, autocomplete: "off") ])
244
+ end,
245
+ submit_tag(label, name: nil, class: "button", data: { its_swiss_live_search_target: "submit" })
246
+ ])
247
+ end
248
+ end
249
+
136
250
  # form_with, already holding the library's builder. An application that
137
251
  # wants its own builder still can; this is the shorthand for the case
138
252
  # where it does not.
@@ -0,0 +1,38 @@
1
+ <section data-specimen="filters">
2
+ <h3>Filters and cards</h3>
3
+ <p class="hint">
4
+ What narrows a list, and the list. A search that filters as you type, and
5
+ a register for each other way of narrowing it: the same two positions
6
+ every time, a quiet label and the choices, the one in force carrying the
7
+ weight. Under it, cards on the page's own fields — this page says a card
8
+ is two fields wide.
9
+ </p>
10
+
11
+ <div class="filters">
12
+ <%= search_form "#filters", frame: "specimen-results", value: "", keep: { tag: "print" } %>
13
+ <%= filter_register "Tagged",
14
+ [ [ "All", "#filters", false ], [ "print", "#filters", true ], [ "screen", "#filters", false ] ],
15
+ name: "tag" %>
16
+ <%= filter_register "Sort by",
17
+ [ [ "Name", "#filters", true ], [ "Newest", "#filters", false ], [ "Most used", "#filters", false ] ],
18
+ name: "sort" %>
19
+ </div>
20
+
21
+ <p class="hint">3 of 12 palettes</p>
22
+
23
+ <ul class="grid cards" style="--card: 2">
24
+ <% [ [ "Brand Core", "4 swatches", 1 ], [ "Press", "6 swatches", 2 ], [ "Screen", "3 swatches", 3 ] ].each do |name, meta, value| %>
25
+ <li class="card">
26
+ <a class="card__link" href="#filters">
27
+ <figure class="card__figure figure" style="--ratio: 1.5">
28
+ <svg viewBox="0 0 3 2" role="img" aria-label="A block of value <%= value %>">
29
+ <rect width="3" height="2" fill="var(--value-<%= value %>)"/>
30
+ </svg>
31
+ </figure>
32
+ <span class="card__name"><%= name %></span>
33
+ </a>
34
+ <span class="card__meta"><%= meta %></span>
35
+ </li>
36
+ <% end %>
37
+ </ul>
38
+ </section>
@@ -5,11 +5,30 @@
5
5
  <nav class="nav">
6
6
  <a href="#masthead" aria-current="page">Where you are</a>
7
7
  <a href="#masthead">Somewhere else</a>
8
- <a href="#masthead">And another</a>
8
+ <%= nav_menu "A menu", current: false do %>
9
+ <a href="#masthead">One of several</a>
10
+ <a href="#masthead">Another of them</a>
11
+ <a href="#masthead">And a third</a>
12
+ <% end %>
13
+ <%= nav_menu "Where you also are", current: true do %>
14
+ <a href="#masthead" aria-current="page">Inside the menu</a>
15
+ <a href="#masthead">Beside it</a>
16
+ <% end %>
9
17
  </nav>
10
18
  </header>
19
+ <nav class="subnav" aria-label="Inside this section">
20
+ <div class="page">
21
+ <a href="#masthead">The section's mark</a>
22
+ <a href="#masthead" aria-current="page">Where you are in it</a>
23
+ <a href="#masthead">Elsewhere in it</a>
24
+ </div>
25
+ </nav>
11
26
  <p class="hint">
12
27
  The current destination carries the accent and the weight. Two signals,
13
- because colour alone is not one.
28
+ because colour alone is not one. A menu is a destination that opens into
29
+ destinations, and carries both when where you are is inside it. The
30
+ subnav is a second layer, inside the one you are in: a shaded band under
31
+ the masthead, rendered by the shell when the application fills
32
+ <code>:subnav</code>.
14
33
  </p>
15
34
  </section>
@@ -0,0 +1,33 @@
1
+ <section data-specimen="page-head">
2
+ <h3>Page head</h3>
3
+ <p class="hint">
4
+ What a page is and what can be done to it: the title and its lede on the
5
+ left, the actions on the right, wrapping under when the page is narrow.
6
+ One shape for every page, so the name and the buttons are always where
7
+ they were on the last one.
8
+ </p>
9
+ <%= page_head "Palettes", lede: "Every palette in the library, including the ones no colour has claimed yet." do %>
10
+ <a href="#page-head" class="button button--primary">New palette</a>
11
+ <a href="#page-head" class="button">Import from a file</a>
12
+ <a href="#page-head" class="button">New from this</a>
13
+ <a href="#page-head" class="button button--danger">Delete palette</a>
14
+ <% end %>
15
+
16
+ <h4>Sections</h4>
17
+ <p class="hint">
18
+ A page with several surfaces names them under the title, the one shown
19
+ in the weight, and shows that one alone. What is done daily is reached
20
+ from the head, never by scrolling past what was done yesterday.
21
+ </p>
22
+ <%= page_head "Deck Chair", sections: [ [ "Compose", "#page-head", true ], [ "Dress", "#page-head", false ], [ "Export", "#page-head", false ] ] do %>
23
+ <a href="#page-head" class="button button--primary">Dress this pattern</a>
24
+ <a href="#page-head" class="button button--quiet">Rename</a>
25
+ <% end %>
26
+
27
+ <h4>Explanations</h4>
28
+ <p class="hint">
29
+ What a section means, behind one mark, for the day it is first read.
30
+ Closed, in the hint register, and there when it is asked for.
31
+ </p>
32
+ <%= explain "The repeat, in order along the stripe normal. Widths sum to one." %>
33
+ </section>
@@ -9,11 +9,13 @@
9
9
  <%= render "its_swiss/specimen/type" %>
10
10
  <%= render "its_swiss/specimen/grid" %>
11
11
  <%= render "its_swiss/specimen/masthead" %>
12
+ <%= render "its_swiss/specimen/page_head" %>
12
13
  <%= render "its_swiss/specimen/buttons" %>
13
14
  <%= render "its_swiss/specimen/form" %>
14
15
  <%= render "its_swiss/specimen/figure" %>
15
16
  <%= render "its_swiss/specimen/table" %>
16
17
  <%= render "its_swiss/specimen/pairs" %>
18
+ <%= render "its_swiss/specimen/filters" %>
17
19
  <%= render "its_swiss/specimen/pagination" %>
18
20
  <%= render "its_swiss/specimen/messages" %>
19
21
  <%= render "its_swiss/specimen/footer" %>
@@ -9,6 +9,7 @@
9
9
  :head anything else that belongs in <head>
10
10
  :mark the wordmark. No mark and no nav means no masthead at all
11
11
  :nav the destinations
12
+ :subnav a second layer of destinations, inside the one you are in
12
13
  :main_class what the page's main region is, if it is a grid
13
14
  :footer whatever belongs after the page
14
15
 
@@ -48,6 +49,10 @@
48
49
 
49
50
  <%= render "its_swiss/shared/masthead" %>
50
51
 
52
+ <% if content_for?(:subnav) %>
53
+ <nav class="subnav" aria-label="Inside this section"><div class="page"><%= content_for(:subnav) %></div></nav>
54
+ <% end %>
55
+
51
56
  <main id="main" class="page <%= content_for(:main_class) %>">
52
57
  <%= render "its_swiss/shared/flash" %>
53
58
  <%= yield %>
data/config/importmap.rb CHANGED
@@ -1,5 +1,7 @@
1
- # The library's whole JavaScript surface. A typographic system does not need
2
- # script to set type; this is here because a value that exists to be taken
3
- # somewhere else should be a button that copies itself, and that cannot be
4
- # done in CSS.
1
+ # The library's whole JavaScript surface: two controllers. A typographic
2
+ # system does not need script to set type; these are here because a value
3
+ # that exists to be taken somewhere else should be a button that copies
4
+ # itself, and a search should narrow the list as you type, and neither can
5
+ # be done in CSS.
5
6
  pin "its_swiss/clipboard_controller", to: "its_swiss/clipboard_controller.js"
7
+ pin "its_swiss/live_search_controller", to: "its_swiss/live_search_controller.js"
@@ -1,3 +1,3 @@
1
1
  module ItsSwiss
2
- VERSION = "0.7.2"
2
+ VERSION = "0.9.1"
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.7.2
4
+ version: 0.9.1
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-09-04 00:00:00.000000000 Z
11
+ date: 2026-09-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: railties
@@ -105,6 +105,7 @@ files:
105
105
  - LICENSE
106
106
  - README.md
107
107
  - app/assets/javascripts/its_swiss/clipboard_controller.js
108
+ - app/assets/javascripts/its_swiss/live_search_controller.js
108
109
  - app/assets/stylesheets/its-swiss.css
109
110
  - app/assets/stylesheets/its_swiss/components.css
110
111
  - app/assets/stylesheets/its_swiss/faces.css
@@ -122,11 +123,13 @@ files:
122
123
  - app/views/its_swiss/shared/_pagination.html.erb
123
124
  - app/views/its_swiss/specimen/_buttons.html.erb
124
125
  - app/views/its_swiss/specimen/_figure.html.erb
126
+ - app/views/its_swiss/specimen/_filters.html.erb
125
127
  - app/views/its_swiss/specimen/_footer.html.erb
126
128
  - app/views/its_swiss/specimen/_form.html.erb
127
129
  - app/views/its_swiss/specimen/_grid.html.erb
128
130
  - app/views/its_swiss/specimen/_masthead.html.erb
129
131
  - app/views/its_swiss/specimen/_messages.html.erb
132
+ - app/views/its_swiss/specimen/_page_head.html.erb
130
133
  - app/views/its_swiss/specimen/_pagination.html.erb
131
134
  - app/views/its_swiss/specimen/_pairs.html.erb
132
135
  - app/views/its_swiss/specimen/_table.html.erb