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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 844fa90d3877d55f3c22b850b00bedfe6156241216b1165aad929ad3540edbd8
4
+ data.tar.gz: 470159b689994419fb692d26c0d1ede1cd48c73508090a4cb9f472ffa95788cc
5
+ SHA512:
6
+ metadata.gz: 40e2d6de3e683bc1adffbaabefd3863e3886ce7462c4e071ed916ff45a743f08b1d590535b6c0732821fa56017299d182045c631da1a1e30a9f84e3c00b5d666
7
+ data.tar.gz: e3ef3c8e93c892330619ad27ac091a0e9a3e9f3b8613db69dda8ed5ddf0da0548e406f83912770a1cfd010612380481906060cc8163aebc85d682846d7b20e52
data/CHANGELOG.md ADDED
@@ -0,0 +1,57 @@
1
+ # Changelog
2
+
3
+ Semver. Consumers pin `~> 0.1`.
4
+
5
+ ## 0.1.0 — 2026-08-31
6
+
7
+ The first extraction, from Pandatone, which ships on it.
8
+
9
+ One consumer, not two, which is what the leading zero is for: the boundary was
10
+ drawn from one real application and the second has not been built yet. Expect
11
+ the surface to move — see "Unproven" below. Pin `~> 0.1` and read this file
12
+ before upgrading.
13
+
14
+ ### Core
15
+
16
+ - Six-step OKLCH value scale, paper to ink, neutral as shipped and warmed by
17
+ two slots. An unset `--accent` falls back to ink.
18
+ - A reset, five type sizes on an alternating 1.33/1.5 ratio, and one vertical
19
+ ladder used for leading as much as for space.
20
+ - Grid primitives — `.page`, `.grid`, `.measure`, `.fields`, `.run`, `.stack` —
21
+ and never a grid. A child says its width with `--span`.
22
+ - Components: masthead and nav, footer, table, form and field, buttons,
23
+ definition list, pagination, errors and hints, copy, skip link.
24
+ - View transition names and durations, with `prefers-reduced-motion` honoured.
25
+ - Everything inside a cascade layer, so an application's own unlayered CSS
26
+ outranks it without out-specifying anything.
27
+
28
+ ### Engine
29
+
30
+ - `its_swiss/shell` layout with `:title`, `:head`, `:mark`, `:nav`,
31
+ `:main_class` and `:footer` slots.
32
+ - Partials: masthead, flash, errors, pagination.
33
+ - `ItsSwiss::FormBuilder` — one field shape, with `for`, `aria-describedby`,
34
+ `aria-invalid` and the refused state wired every time.
35
+ - Helpers: `its_swiss_stylesheet_tags`, `nav_link_to`, `copy_button`,
36
+ `its_swiss_form_with`, `its_swiss_page_numbers`.
37
+ - One Stimulus controller, pinned by the engine rather than by the installer.
38
+ - `its_swiss:install` generator: the theme file, the layout, the specimen route.
39
+ - `/its-swiss/specimen`, rendered twice — accent unset and accent set.
40
+
41
+ ### Not in it, deliberately
42
+
43
+ - No typeface, no palette, no hue.
44
+ - No fixed grid, no `.span-N` classes, no utility classes.
45
+ - `.tag`, `.swatch` and the filter block stay in Pandatone until a second
46
+ application asks for them.
47
+
48
+ ### Unproven
49
+
50
+ `.table` and `.pagination` have **no consumer at all**. Pandatone's migration
51
+ used neither, so the only thing that has ever rendered them is the specimen.
52
+ They are in the boundary the handoff drew and they break the library's own
53
+ rule about two applications; they are the two components most likely to move
54
+ when Stripeclub lands.
55
+
56
+ `.footer` is the same shape of guess — the shell has the slot and Pandatone
57
+ does not fill it.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bobby Meyer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,262 @@
1
+ # its-swiss
2
+
3
+ A Swiss typographic style for Rails applications, in two layers.
4
+
5
+ The **core** is plain CSS and one small piece of JavaScript: tokens, a reset,
6
+ typography, grid primitives, components and view transition rules. It needs
7
+ nothing but a `<link>` tag.
8
+
9
+ The **engine** ships that core through `app/assets`, plus a base layout shell,
10
+ partials, a form builder, helpers and an install generator.
11
+
12
+ It is monochrome. A six-step value scale does the work and the one accent is
13
+ the consuming application's to set — it is not a theme with the colour left
14
+ out, it is a style whose argument is that the values are enough.
15
+
16
+ ```ruby
17
+ gem "its-swiss"
18
+ ```
19
+
20
+ ```sh
21
+ bin/rails generate its_swiss:install
22
+ ```
23
+
24
+ Then open `/its-swiss/specimen`.
25
+
26
+ ## What is in the gem, and what stays in the application
27
+
28
+ | In the gem | Stays in the application |
29
+ | --- | --- |
30
+ | Tokens, reset, base typography | The grid itself — which blocks span which fields |
31
+ | Masthead, nav, footer, table, form, button, definition list, pagination | Domain components |
32
+ | A base layout shell with `content_for` slots | Page layouts beyond the shell |
33
+ | View transition names and durations | Which pages transition to which |
34
+ | The value scale and the accent slot | Any hue, any palette knowledge |
35
+ | A fallback grotesque stack | The typeface |
36
+
37
+ A pattern enters the gem after it appears in two applications, not before.
38
+
39
+ ### The boundary is the cascade, not a convention
40
+
41
+ Everything the gem ships is inside a cascade layer. An application's own CSS is
42
+ unlayered, and an unlayered rule beats every layered one whatever its
43
+ specificity — so the application always wins, without having to out-specify
44
+ anything or reach for `!important`.
45
+
46
+ ```
47
+ @layer its-swiss.tokens, its-swiss.reset, its-swiss.type,
48
+ its-swiss.grid, its-swiss.components, its-swiss.transitions;
49
+ ```
50
+
51
+ Each file declares its own layer, so linking the six files individually (what
52
+ `its_swiss_stylesheet_tags` does) and linking the single `its-swiss.css` that
53
+ imports them resolve identically.
54
+
55
+ ## The slots an application fills
56
+
57
+ `bin/rails generate its_swiss:install` writes `app/assets/stylesheets/theme.css`
58
+ holding all of them. Nothing here has a default the gem could pick honestly.
59
+
60
+ | Slot | What it is |
61
+ | --- | --- |
62
+ | `--accent`, `--accent-ink` | State and emphasis only. Unset, the accent is ink |
63
+ | `--font-family` | The typeface. The gem ships none — declare `@font-face` and name it |
64
+ | `--value-chroma`, `--value-hue` | Warms the whole value scale together. Neutral as shipped |
65
+ | `--columns`, `--gutter`, `--baseline` | How many fields this problem has, and the unit everything vertical is measured in |
66
+
67
+ ### The value scale
68
+
69
+ Six steps of OKLCH lightness, paper to ink. OKLCH because the steps have to be
70
+ perceptually even to read as a scale at all — the same arithmetic in sRGB puts
71
+ four of the six inside the top quarter of the range.
72
+
73
+ | Token | Lightness | Alias | For |
74
+ | --- | --- | --- | --- |
75
+ | `--value-0` | 98% | `--paper` | The ground |
76
+ | `--value-1` | 94% | `--paper-shaded` | A shaded ground |
77
+ | `--value-2` | 89% | `--rule` | A hairline |
78
+ | `--value-3` | 72% | `--rule-strong` | A rule that has to be seen |
79
+ | `--value-4` | 54% | `--ink-quiet` | Secondary text — 4.7:1 on paper |
80
+ | `--value-5` | 18% | `--ink` | Ink |
81
+
82
+ The ladder is deliberately not evenly spaced: a hairline has to be barely
83
+ there, so the top is dense; the middle only has to hold secondary text away
84
+ from primary, so it is sparse. An even ladder gives a usable mid-gray and no
85
+ usable rule.
86
+
87
+ Neutral as shipped. `--value-chroma: 0.006; --value-hue: 95` turns the whole
88
+ ladder to the warm grays of a printed page — and, near enough, to Pandatone's:
89
+
90
+ | Step | Warmed | Pandatone had |
91
+ | --- | --- | --- |
92
+ | `--value-0` | `#FAF8F4` | `#FAF9F7` `--ground` |
93
+ | `--value-2` | `#DCDBD6` | `#DCDAD4` `--keyline` |
94
+ | `--value-4` | `#706F6B` | `#6F6F6A` `--ink-quiet` |
95
+ | `--value-5` | `#12120F` | `#111111` `--ink` |
96
+
97
+ Within three units a channel, which is the cost of the four hand-picked
98
+ values becoming one ladder with a single chroma and a single hue. Measured in
99
+ Chromium, not calculated.
100
+
101
+ ### The grid
102
+
103
+ The gem ships primitives and never a grid.
104
+
105
+ ```css
106
+ .page /* the one measured container: --page-max wide, --page-inset either side */
107
+ .grid /* repeat(var(--columns), minmax(0, 1fr)) with --gutter between */
108
+ .measure /* three fields wide */
109
+ .fields /* --span fields wide */
110
+ .run /* items on a shared baseline, a space apart, wrapping when they must */
111
+ .stack /* the same, turned ninety degrees */
112
+ ```
113
+
114
+ A child of `.grid` says how many fields it takes with `--span`; a child that
115
+ says nothing runs the whole field, because that is what nearly everything on a
116
+ page does.
117
+
118
+ `--field` is derived from the page rather than measured, so anything built on
119
+ it stops on a field line:
120
+
121
+ ```css
122
+ --field: calc((var(--page-max) - var(--page-inset) * 2
123
+ - var(--gutter) * (var(--columns) - 1)) / var(--columns));
124
+ --measure: calc(var(--field) * 3 + var(--gutter) * 2);
125
+ ```
126
+
127
+ ## The layout shell
128
+
129
+ ```ruby
130
+ class ApplicationController < ActionController::Base
131
+ layout "its_swiss/shell"
132
+ end
133
+ ```
134
+
135
+ Slots, all optional:
136
+
137
+ | Slot | |
138
+ | --- | --- |
139
+ | `:title` | Falls back to the application's name |
140
+ | `:head` | Anything else that belongs in `<head>` |
141
+ | `:mark` | The wordmark. No mark and no nav means no masthead at all |
142
+ | `:nav` | The destinations |
143
+ | `:main_class` | What the page's main region is, if it is a grid |
144
+ | `:footer` | Whatever belongs after the page |
145
+
146
+ The shell writes the view transition opt-in, the six stylesheet links, the
147
+ importmap tags, a skip link, and the flash. It stops there — a page layout
148
+ beyond the shell is the application's, for the same reason its grid is.
149
+
150
+ ## Helpers
151
+
152
+ | | |
153
+ | --- | --- |
154
+ | `its_swiss_stylesheet_tags` | The six links, tracked for Turbo |
155
+ | `nav_link_to(name, url, current:)` | A destination, with `aria-current` when you are at it |
156
+ | `copy_button(value)` | A value that copies itself |
157
+ | `its_swiss_form_with(...)` | `form_with`, already holding the library's builder |
158
+ | `its_swiss_page_numbers(page, pages)` | Which numbers a run of them shows, elided |
159
+
160
+ ## The form builder
161
+
162
+ One shape for every field: a label, a control, and — when there is something to
163
+ say — a hint or the reason it was refused.
164
+
165
+ ```erb
166
+ <%= its_swiss_form_with model: @palette do |form| %>
167
+ <%= form.text_field :name, hint: "As it appears in the nav." %>
168
+ <%= form.check_box :published %>
169
+ <%= form.submit "Save" %>
170
+ <% end %>
171
+ ```
172
+
173
+ It exists because the parts that get left out by hand are the parts nobody
174
+ sees missing: a label whose `for` does not match its input does not enlarge the
175
+ target; a hint beside a control is a hint a screen reader never reaches; a
176
+ refused field coloured by CSS alone is a refusal only some readers get. The
177
+ builder wires `for`, `aria-describedby`, `aria-invalid` and `.field--invalid`
178
+ every time.
179
+
180
+ `label: false` hides the label rather than removing it — the name moves onto
181
+ the control as `aria-label`.
182
+
183
+ ## The specimen
184
+
185
+ `/its-swiss/specimen`, mounted by the installer under `if Rails.env.development?`
186
+ and refused a second time by the controller, because a route is a line in a file
187
+ someone can move.
188
+
189
+ It renders every component, the type scale, the value scale and the grid
190
+ primitives — **twice**. The first take is the library exactly as it ships, with
191
+ the accent collapsed onto ink; the second sets an accent and changes nothing
192
+ else. If the first reads correctly, the value scale is doing the work.
193
+
194
+ It is the documentation and the regression fixture.
195
+
196
+ ## Tests
197
+
198
+ ```sh
199
+ bin/test # everything; browser tests skip
200
+ CHROME_BINARY=... CHROMEDRIVER=... bin/test # including the browser tests
201
+ ```
202
+
203
+ Tests come first, and a guard is only kept if removing what it guards makes it
204
+ fail.
205
+
206
+ Most of the suite reads the CSS rather than rendering it, which is enough for
207
+ the decisions that are decisions about the source: that the value scale is a
208
+ descending ladder, that no stylesheet invents a colour or a column count, that
209
+ every line box is measured in baselines, that no signal rests on colour alone.
210
+
211
+ The rest needs a browser, because a rule on the wrong selector reads correctly
212
+ in the CSS and does nothing on a page. Those assert what Chromium actually
213
+ 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.
218
+
219
+ There are no pixel tests.
220
+
221
+ ## Coming from Pandatone
222
+
223
+ The token names changed where the boundary moved. The value scale is the same
224
+ ladder; the names are the library's rather than the application's.
225
+
226
+ | Pandatone | its-swiss |
227
+ | --- | --- |
228
+ | `--ground` | `--paper` |
229
+ | `--keyline` | `--rule` |
230
+ | `--ink-quiet`, `--ink` | unchanged |
231
+ | `--accent` | unchanged — still the application's to set |
232
+ | `--font` | `--font-family` |
233
+ | `--size-1..5`, `--space-N`, `--baseline`, `--measure`, `--page-max` | unchanged |
234
+ | `.masthead__nav` | `.nav` |
235
+ | `.channels` | `.pairs` |
236
+ | `.form`, `.field`, `.button*`, `.copy`, `.errors`, `.hint`, `.empty` | unchanged |
237
+ | `--columns-dense`, `--card`, `--card-wide`, `.swatch*`, `.tag*`, `.filter*` | stay in Pandatone |
238
+
239
+ Two behavioural differences to know about:
240
+
241
+ - **The masthead no longer sits on the page's field.** It lays itself out in
242
+ flex, so it does not break when an application changes `--columns`.
243
+ - **`--field` now subtracts the page's own inset** before dividing. Pandatone's
244
+ `--measure` was a gutter and a half wider than three fields; anything relying
245
+ on the old number moves in by 24px.
246
+ - **`.button--danger` is set apart by a step on the ladder, not an auto
247
+ margin.** Pandatone scoped the auto margin to two named regions; a library
248
+ cannot, and an auto margin across a whole page reads as a different control
249
+ rather than a separated one.
250
+
251
+ ## Versioning
252
+
253
+ Semver with a changelog. Consumers pin `~> 0.1`.
254
+
255
+ `0.1.0` ships with one consumer — Pandatone — and that is what the leading
256
+ zero is for: the boundary was drawn from one real application and the second
257
+ has not been built yet. The surface will move. Read `CHANGELOG.md` before
258
+ upgrading, and see `RELEASING.md` for how a version gets out.
259
+
260
+ ## License
261
+
262
+ MIT.
@@ -0,0 +1,33 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // The library's whole JavaScript surface. A typographic system does not need
4
+ // script to set type; this is here because a value that exists to be taken
5
+ // somewhere else should be a button that copies itself, and that cannot be
6
+ // done in CSS.
7
+ //
8
+ // The value is an attribute rather than the button's own text, so a button
9
+ // that shows a value shortened for the page still copies the whole of it.
10
+ export default class extends Controller {
11
+ static values = { text: String, saidFor: { type: Number, default: 1200 } }
12
+
13
+ async copy() {
14
+ try {
15
+ await navigator.clipboard.writeText(this.textValue || this.element.textContent.trim())
16
+ } catch {
17
+ // A refused or unavailable clipboard is not an error the page should
18
+ // report: the value is still on screen, still selectable, and saying
19
+ // "copied" when nothing was copied is the only outcome worth avoiding.
20
+ return
21
+ }
22
+
23
+ // Says it worked in the one place you are already looking. The attribute
24
+ // is the whole signal; what it looks like is components.css's business.
25
+ this.element.setAttribute("data-copied", "")
26
+ clearTimeout(this.timeout)
27
+ this.timeout = setTimeout(() => this.element.removeAttribute("data-copied"), this.saidForValue)
28
+ }
29
+
30
+ disconnect() {
31
+ clearTimeout(this.timeout)
32
+ }
33
+ }
@@ -0,0 +1,16 @@
1
+ /* The whole library in one <link>.
2
+ *
3
+ * The layer order is stated before the first import rather than left to the
4
+ * order the files arrive in, so a slow network cannot reorder the cascade.
5
+ * Every file states its own layer as well, which is what makes this file and
6
+ * six separate <link> tags resolve identically — the Rails helper writes the
7
+ * six, and anything else can point at this.
8
+ */
9
+ @layer its-swiss.tokens, its-swiss.reset, its-swiss.type, its-swiss.grid, its-swiss.components, its-swiss.transitions;
10
+
11
+ @import url("its_swiss/tokens.css");
12
+ @import url("its_swiss/reset.css");
13
+ @import url("its_swiss/type.css");
14
+ @import url("its_swiss/grid.css");
15
+ @import url("its_swiss/components.css");
16
+ @import url("its_swiss/transitions.css");