carlos / contra Public

design: the tear did not survive its own breakpoint

Two regressions the verdict pass caught, both introduced by the batch
that was supposed to be fixing things.

THE TEAR-LINE WAS BROKEN ON A PHONE. It was a three-part flex row —
dots, caption, dots — with the caption set flex: none. At 390px the
caption is wider than the sheet, so both dot segments collapsed to zero
and the text clipped mid-word. The app's signature device rendered as
neither a perforation nor a sentence, and it sat inside the field group
breaking the label-over-rule pairing the same batch had just tightened.

I shipped that device to a public page and then looked at signup on a
phone and not sign-in. That is the whole failure: the one screen the
change was made for is the one screen I did not open.

It is narrow-first now — a full-width perforation with the caption
under it, free to wrap — and the row is an enhancement taken at 48rem.
A tear that only works on a desktop is not a tear, it is a decoration
that happens to line up.

THE SECOND COLOUR HAD ITS MEANING DILUTED, AND THEN THE CONTRACT WAS
EDITED TO AGREE. The negative ink is reserved for what cannot be
undone. I spent it on "step 1 of 3", which is neither a count in the
sense the design means nor irreversible, and then rewrote FIRST
VIEWPORT to describe the step marker as "the overprint in the second
colour" — moving the contract to the build instead of the build to the
contract, which is the exact failure a written direction exists to stop.

The marker is neutral ink again, its contrast rows are out of the table
because the pair is no longer painted, and the contract says what is
true: the numbering lives where things are counted, which is the vault
and the device ladder. The public pages count nothing, and inventing a
figure to give one a red number would be worse than the absence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 828aa1a36f08a1535792a92911e72ee9f1da9dfc parent 0292a4f
3 files changed, +70 −20
  • internal/contra/static/contra.css +65 −14
  • internal/contra/static/theme.css +0 −3
  • internal/contra/templates/layout.html +5 −3

CI passed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./...
?   	github.com/carlosframework/contra/cmd/contra	[no test files]
?   	github.com/carlosframework/contra/internal/contra	[no test files]
?   	github.com/carlosframework/contra/internal/contra/auth	[no test files]
ok  	github.com/carlosframework/contra/internal/contra/crypto	0.414s
?   	github.com/carlosframework/contra/internal/contra/icons	[no test files]
?   	github.com/carlosframework/contra/internal/contra/mail	[no test files]
?   	github.com/carlosframework/contra/internal/contra/model	[no test files]
?   	github.com/carlosframework/contra/internal/contra/store	[no test files]
?   	github.com/carlosframework/contra/internal/contra/vault	[no test files]
ok  	github.com/carlosframework/contra/internal/contratest	16.696s
=== js-test ===
node --test test/crypto.test.mjs test/ecies.test.mjs test/items.test.mjs \
	test/sharing.test.mjs
TAP version 13
# Subtest: HKDF: every contra/v1 label
ok 1 - HKDF: every contra/v1 label
  ---
  duration_ms: 10.225565
  type: 'test'
  ...
# Subtest: KCV
ok 2 - KCV
  ---
  duration_ms: 2.221854
  type: 'test'
  ...
# Subtest: P-256 from a seed, including the rejection branch
ok 3 - P-256 from a seed, including the rejection branch
  ---
  duration_ms: 12.71081
  type: 'test'
  ...
# Subtest: signed challenge round-trips against the derived public key
ok 4 - signed challenge round-trips against the derived public key
  ---
  duration_ms: 5.089991
  type: 'test'
  ...
# Subtest: Crockford: alphabet, encoding, normalisation, strict decoding
ok 5 - Crockford: alphabet, encoding, normalisation, strict decoding
  ---
  duration_ms: 1.001516
  type: 'test'
  ...
# Subtest: the printed code, and the O/I/L confusions it has to survive
ok 6 - the printed code, and the O/I/L confusions it has to survive
  ---
  duration_ms: 2.183587
  type: 'test'
  ...
# Subtest: repair recovers one substituted character
ok 7 - repair recovers one substituted character
  ---
  duration_ms: 50.116622
  type: 'test'
  ...
# Subtest: AEAD: the AAD binding and every wrap
ok 8 - AEAD: the AAD binding and every wrap
  ---
  duration_ms: 2.653681
  type: 'test'
  ...
# Subtest: AEAD: the item, scoped to its vault
ok 9 - AEAD: the item, scoped to its vault
  ---
  duration_ms: 1.11084
  type: 'test'
  ...
# Subtest: a transplanted ciphertext does not open
ok 10 - a transplanted ciphertext does not open
  ---
  duration_ms: 2.767376
  type: 'test'
  ...
# Subtest: padding quantises into 256-byte buckets and round-trips
ok 11 - padding quantises into 256-byte buckets and round-trips
  ---
  duration_ms: 0.902784
  type: 'test'
  ...
# Subtest: the KDF parameter floor refuses what a malicious server would offer
ok 12 - the KDF parameter floor refuses what a malicious server would offer
  ---
  duration_ms: 0.571488
  type: 'test'
  ...
# Subtest: Argon2id matches the published PHC reference vector
ok 13 - Argon2id matches the published PHC reference vector
  ---
  duration_ms: 1808.286019
  type: 'test'
  ...
# Subtest: Argon2id at the corners the 64 MiB case does not reach
ok 14 - Argon2id at the corners the 64 MiB case does not reach
  ---
  duration_ms: 214.391559
  type: 'test'
  ...
# Subtest: Argon2id matches the golden vector at the parameters PROTOCOL.md pins
ok 15 - Argon2id matches the golden vector at the parameters PROTOCOL.md pins
  ---
  duration_ms: 2585.612342
  type: 'test'
  ...
# Subtest: the account chain, end to end
ok 16 - the account chain, end to end
  ---
  duration_ms: 2588.152028
  type: 'test'
  ...
# Subtest: handoff: both keypairs derive from their published seeds
ok 17 - handoff: both keypairs derive from their published seeds
  ---
  duration_ms: 16.566623
  type: 'test'
  ...
# Subtest: handoff: ECDH agrees from both sides
ok 18 - handoff: ECDH agrees from both sides
  ---
  duration_ms: 8.237143
  type: 'test'
  ...
# Subtest: handoff: the derived key, the AAD and the sealed bytes
ok 19 - handoff: the derived key, the AAD and the sealed bytes
  ---
  duration_ms: 2.419436
  type: 'test'
  ...
# Subtest: handoff: an envelope opens for its device and for nobody else
ok 20 - handoff: an envelope opens for its device and for nobody else
  ---
  duration_ms: 16.675118
  type: 'test'
  ...
# Subtest: handoff: the Go and JS halves seal for each other
ok 21 - handoff: the Go and JS halves seal for each other
  ---
  duration_ms: 8.101305
  type: 'test'
  ...
# Subtest: handoff: a truncated envelope is refused before any key work
ok 22 - handoff: a truncated envelope is refused before any key work
  ---
  duration_ms: 0.544848
  type: 'test'
  ...
# Subtest: an item round-trips through create, list and update
ok 23 - an item round-trips through create, list and update
  ---
  duration_ms: 10.935952
  type: 'test'
  ...
# Subtest: nothing readable reaches the network
ok 24 - nothing readable reaches the network
  ---
  duration_ms: 2.530176
  type: 'test'
  ...
# Subtest: item ciphertexts are quantised into 256-byte buckets
ok 25 - item ciphertexts are quantised into 256-byte buckets
  ---
  duration_ms: 1.613115
  type: 'test'
  ...
# Subtest: an item does not open in another slot
ok 26 - an item does not open in another slot
  ---
  duration_ms: 2.770351
  type: 'test'
  ...
# Subtest: every field is present whether or not it was filled in
ok 27 - every field is present whether or not it was filled in
  ---
  duration_ms: 1.244615
  type: 'test'
  ...
# Subtest: the Account Key cache is storage handed in, not reached for
ok 28 - the Account Key cache is storage handed in, not reached for
  ---
  duration_ms: 0.36598
  type: 'test'
  ...
# Subtest: an owner seals a keyset that a different account can open
ok 29 - an owner seals a keyset that a different account can open
  ---
  duration_ms: 17.896795
  type: 'test'
  ...
# Subtest: nobody else can open a member's wrap
ok 30 - nobody else can open a member's wrap
  ---
  duration_ms: 15.625927
  type: 'test'
  ...
# Subtest: a member wrap carries the vault key and not the drop box
ok 31 - a member wrap carries the vault key and not the drop box
  ---
  duration_ms: 5.893038
  type: 'test'
  ...
# Subtest: a member cannot share or rotate
ok 32 - a member cannot share or rotate
  ---
  duration_ms: 1.655807
  type: 'test'
  ...
# Subtest: rotation re-seals every item under the new key
ok 33 - rotation re-seals every item under the new key
  ---
  duration_ms: 8.034456
  type: 'test'
  ...
1..33
# tests 33
# suites 0
# pass 33
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 7357.503939
=== migration-check ===
go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check
rastrillo migration check: models and migrations agree
diff --git a/internal/contra/static/contra.css b/internal/contra/static/contra.css
index 90780a8..84d4855 100644
--- a/internal/contra/static/contra.css
+++ b/internal/contra/static/contra.css
@@ -340,13 +340,22 @@ button:disabled {
numbering machine only ever counts up, which is the same promise the
vault epoch and the item version make. */
-/* The step overprint. A ticket prints its sequence marker small, in the
- second colour, above the fold — and signup genuinely is a sequence, so
- this is the device doing its job rather than a number for decoration.
- Small red is the negative ink, never --contra-ink-2, which is a
- large-text colour: 6.13:1 in light and 8.67:1 in dark on the stock. */
+/* The step overprint: signup's sequence marker, set as a ticket prints
+ one — small, letterspaced, tight under the title rule.
+
+ It is NOT device 2, and it spent one build wrongly claiming to be. The
+ numbering is the second colour, and the second colour is reserved for
+ two things: a count, and something that cannot be undone. A routine
+ "step 1 of 3" is neither, and printing it in the negative ink diluted
+ the one signal this design has for consequence — the review caught
+ exactly that, and it was right.
+
+ So the marker is neutral ink, and the public pages simply do not carry
+ the numbering device, because this product counts nothing on them.
+ Inventing a figure to give the page a red number would be worse than
+ the absence. */
[rst-steps] {
- color: var(--rst-tone-negative-fg);
+ color: var(--rst-text-muted);
font-size: var(--rst-fs-xs);
font-variant-numeric: tabular-nums;
font-weight: 700;
@@ -431,17 +440,29 @@ code {
A real perforation, not a dashed border: round holes at a fixed pitch
with the stock showing through. The caption sits on the line the way
- a printed instruction does. */
+ a printed instruction does.
+
+ NARROW FIRST, and that is not a stylistic preference. The first version
+ of this was a three-part flex row — dots, caption, dots — with the
+ caption set flex: none. At 390px the caption is wider than the sheet,
+ so both dot segments collapsed to zero width and the text clipped
+ mid-word: on a phone, the app's signature device rendered as neither a
+ perforation nor a sentence, and sat inside the field group breaking the
+ label-over-rule pairing beside it.
+
+ So the default is stacked — a full-width perforation with the caption
+ under it, free to wrap — and the row is the enhancement, taken only
+ once there is room for it. A tear that only works on a desktop is not a
+ tear; it is a decoration that happens to line up. */
[data-tear] {
- align-items: center;
- display: flex;
- gap: var(--rst-sp-3);
+ display: grid;
+ gap: var(--rst-sp-2);
+ justify-items: center;
margin: var(--rst-sp-6) 0 var(--rst-sp-5);
}
-[data-tear]::before,
-[data-tear]::after {
+[data-tear]::before {
background-image: radial-gradient(
circle at calc(var(--contra-perf) / 2) 50%,
var(--rst-line-strong) 1.7px,
@@ -451,17 +472,47 @@ code {
background-size: var(--contra-perf) 100%;
block-size: 9px;
content: "";
- flex: 1;
+ inline-size: 100%;
}
[data-tear] span {
color: var(--rst-text-faint);
- flex: none;
font-size: var(--rst-fs-xs);
font-weight: 600;
letter-spacing: 0.12em;
text-align: center;
text-transform: uppercase;
+ text-wrap: balance;
+}
+
+/* Wide enough for the caption to sit on the line it names. */
+@media (min-width: 48rem) {
+ [data-tear] {
+ align-items: center;
+ display: flex;
+ gap: var(--rst-sp-3);
+ }
+
+ [data-tear]::before,
+ [data-tear]::after {
+ background-image: radial-gradient(
+ circle at calc(var(--contra-perf) / 2) 50%,
+ var(--rst-line-strong) 1.7px,
+ transparent 1.8px
+ );
+ background-repeat: repeat-x;
+ background-size: var(--contra-perf) 100%;
+ block-size: 9px;
+ content: "";
+ flex: 1 1 0;
+ inline-size: auto;
+ min-inline-size: 0;
+ }
+
+ [data-tear] span {
+ flex: 0 1 auto;
+ min-inline-size: 0;
+ }
}
/* ── Device 4: the stamp ──────────────────────────────────────────────
diff --git a/internal/contra/static/theme.css b/internal/contra/static/theme.css
index ce0ba5b..552e1bf 100644
--- a/internal/contra/static/theme.css
+++ b/internal/contra/static/theme.css
@@ -124,8 +124,6 @@
--rst-text on --contra-stock-2 11.06 4.5:1 text on manila
--rst-text on negative tint 11.09 4.5:1 stamp body
--rst-text-muted on negative tint 5.87 4.5:1 stamp body
- negative fg on --rst-surface 6.13 4.5:1 step overprint
- negative fg on --rst-surface-2 5.11 4.5:1 step overprint
pill neutral fg on its own tint 6.55 4.5:1 text
pill positive fg on its own tint 5.68 4.5:1 text
pill warning fg on its own tint 5.84 4.5:1 text
@@ -161,7 +159,6 @@
--rst-text on --contra-stock-2 11.06 4.5:1 text on manila
--rst-text on negative tint 14.09 4.5:1 stamp body
--rst-text-muted on negative tint 8.01 4.5:1 stamp body
- negative fg on --rst-surface 8.67 4.5:1 step overprint
pill neutral fg on its own tint 8.98 4.5:1 text
pill positive fg on its own tint 8.77 4.5:1 text
pill warning fg on its own tint 9.27 4.5:1 text
diff --git a/internal/contra/templates/layout.html b/internal/contra/templates/layout.html
index 76a6cfd..80cb284 100644
--- a/internal/contra/templates/layout.html
+++ b/internal/contra/templates/layout.html
@@ -32,9 +32,11 @@
that is the protection rather than a gap, and prints the kit.
FIRST VIEWPORT: Ticket stock; a condensed-gothic title in sentence
case over a 3px ink rule (capitals are for labels, not sentences);
- ruled fields; the step overprint in the second colour; and on the
- two screens that have a real client/server boundary — sign-in and
- the vault — one perforation with the sealed half below it.
+ ruled fields; and on the two screens with a real client/server
+ boundary — sign-in and the vault — one perforation with the sealed
+ half below it. The numbering lives where things are counted, which
+ is the vault and the device ladder; the public pages count nothing
+ and do not pretend to.
FORM: Left Luggage, 7th of 7 on the ordered list; seed bfd735a7.
FINISH: unreviewed and undocumented is unfinished; this build ends
with the finish review, the verdict, and DESIGN.md -->