carlos / contra Public

design: what the first canary round showed

One batched inspection, desktop and mobile, against the deployed build
rather than a localhost guess. Five things, fixed together.

The field box had its label floating half an inch above a rule it clearly
did not belong to — the label read as a caption for the whitespace. A
printed field is one mark: the label sits directly on its rule now.

The sheet's cut edges stopped wherever the content happened to end, so a
short page rendered as an unfinished box two thirds of the way down. The
stock now runs the full sheet.

A serial in a grey chip stranded the full stop after "Build 3a8ba10."
half a space from it. Serials print on a ruled line, which is both the
fix and the more accurate mark.

The second colour appeared nowhere. On the front page the preview
warning is a condition of carriage, so it prints as one — the stamp
device, which is exactly where this design is allowed to spend
vermilion. The words are untouched: copy is a reviewed artifact here and
this is a change of print, not of language.

And the vault page gained its counterfoil: everything above the tear —
the session, which vaults you can reach, their opaque ids — is the
server's half of the ticket and now prints on the tint block, which is
also the density the flat sheet was missing. Both new grounds are
measured into theme.css's table.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 9030879e1628115270f6b200763d92c9fb5f7d03 parent 3a8ba10
4 files changed, +76 −5
  • internal/contra/static/contra.css +57 −5
  • internal/contra/static/theme.css +4 −0
  • internal/contra/templates/index.html +6 −0
  • internal/contra/templates/vault.html +9 −0

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.412s
?   	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.764s
=== 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.868466
  type: 'test'
  ...
# Subtest: KCV
ok 2 - KCV
  ---
  duration_ms: 1.580995
  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.867464
  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.077408
  type: 'test'
  ...
# Subtest: Crockford: alphabet, encoding, normalisation, strict decoding
ok 5 - Crockford: alphabet, encoding, normalisation, strict decoding
  ---
  duration_ms: 1.023916
  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.274103
  type: 'test'
  ...
# Subtest: repair recovers one substituted character
ok 7 - repair recovers one substituted character
  ---
  duration_ms: 48.005116
  type: 'test'
  ...
# Subtest: AEAD: the AAD binding and every wrap
ok 8 - AEAD: the AAD binding and every wrap
  ---
  duration_ms: 2.778144
  type: 'test'
  ...
# Subtest: AEAD: the item, scoped to its vault
ok 9 - AEAD: the item, scoped to its vault
  ---
  duration_ms: 1.187864
  type: 'test'
  ...
# Subtest: a transplanted ciphertext does not open
ok 10 - a transplanted ciphertext does not open
  ---
  duration_ms: 2.022533
  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: 1.590977
  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.651644
  type: 'test'
  ...
# Subtest: Argon2id matches the published PHC reference vector
ok 13 - Argon2id matches the published PHC reference vector
  ---
  duration_ms: 1801.54834
  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: 213.577366
  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: 2567.732001
  type: 'test'
  ...
# Subtest: the account chain, end to end
ok 16 - the account chain, end to end
  ---
  duration_ms: 2585.109146
  type: 'test'
  ...
# Subtest: handoff: both keypairs derive from their published seeds
ok 17 - handoff: both keypairs derive from their published seeds
  ---
  duration_ms: 16.83256
  type: 'test'
  ...
# Subtest: handoff: ECDH agrees from both sides
ok 18 - handoff: ECDH agrees from both sides
  ---
  duration_ms: 8.298269
  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.499417
  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: 17.104678
  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.272821
  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.560214
  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: 11.181445
  type: 'test'
  ...
# Subtest: nothing readable reaches the network
ok 24 - nothing readable reaches the network
  ---
  duration_ms: 2.870007
  type: 'test'
  ...
# Subtest: item ciphertexts are quantised into 256-byte buckets
ok 25 - item ciphertexts are quantised into 256-byte buckets
  ---
  duration_ms: 1.685894
  type: 'test'
  ...
# Subtest: an item does not open in another slot
ok 26 - an item does not open in another slot
  ---
  duration_ms: 2.942509
  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.225326
  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.355545
  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: 18.03247
  type: 'test'
  ...
# Subtest: nobody else can open a member's wrap
ok 30 - nobody else can open a member's wrap
  ---
  duration_ms: 15.988418
  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: 6.035688
  type: 'test'
  ...
# Subtest: a member cannot share or rotate
ok 32 - a member cannot share or rotate
  ---
  duration_ms: 1.75794
  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: 7.847201
  type: 'test'
  ...
1..33
# tests 33
# suites 0
# pass 33
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 7329.082213
=== 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 6635dc1..25b8d97 100644
--- a/internal/contra/static/contra.css
+++ b/internal/contra/static/contra.css
@@ -102,9 +102,14 @@ a:hover {
/* The page is one piece of stock. tokens.css already centres the column;
this gives it the ticket's cut edge on the wide layout, where there is
ground either side to cut it out of. */
+/* The page is one piece of stock, so its cut edges run the whole sheet
+ rather than stopping wherever the content happens to end — a rule that
+ halts two thirds of the way down reads as an unfinished box, which is
+ what the first canary round showed. */
[rst-page] {
border-inline: 1px solid var(--rst-line);
max-width: 60rem;
+ min-block-size: calc(100vh - 4rem);
}
/* The brand is the ticket's overprint: the issuing office's name, set
@@ -181,7 +186,7 @@ label {
font-size: var(--rst-fs-xs);
font-weight: 600;
letter-spacing: 0.12em;
- margin-bottom: var(--rst-sp-1);
+ margin-bottom: 0;
text-transform: uppercase;
}
@@ -215,7 +220,10 @@ select {
font: inherit;
inline-size: 100%;
max-inline-size: 32rem;
- padding: var(--rst-sp-2) 0;
+ /* The value sits ON the rule, close under its label: a printed field
+ is one mark, and the first round had the label floating half an inch
+ above a rule it clearly did not belong to. */
+ padding: 0.15rem 0 var(--rst-sp-1);
}
textarea {
@@ -386,14 +394,17 @@ button:disabled {
/* Opaque identifiers are machine-printed serials: the account id you
hand somebody, the build stamp, a handoff code. Monospace here is
measurement, not costume — these are hex and Crockford Base32, and
- the reader has to transcribe them by hand. */
+ the reader has to transcribe them by hand.
+
+ A serial is printed on a ruled line, not in a grey box. The box also
+ stranded the full stop after "Build 3a8ba10." half a space away from
+ it, which is the kind of thing a chip does to a sentence. */
code {
- background: var(--rst-surface-2);
+ border-bottom: 1px solid var(--rst-line-strong);
font-family: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 0.95em;
font-variant-numeric: tabular-nums;
letter-spacing: 0.06em;
- padding: 0.1em 0.35em;
word-break: break-all;
}
@@ -460,6 +471,16 @@ code {
color: var(--rst-tone-negative-fg);
}
+[data-notice] > :first-child,
+[data-kind="warning"] > :first-child {
+ margin-block-start: 0;
+}
+
+[data-notice] > :last-child,
+[data-kind="warning"] > :last-child {
+ margin-block-end: 0;
+}
+
/* ── Device 5: the stock code ─────────────────────────────────────────
Manila, and it means one thing: this vault belongs to somebody else
and you are on the list. Colour as a category, never as decoration. */
@@ -530,6 +551,37 @@ code {
text-decoration: underline;
}
+/* The disclosures are controls, so they speak the same vocabulary as
+ every other control rather than arriving in title case from the
+ framework's defaults. */
+[rst-shell-menu] > summary,
+[rst-shell-account] > summary {
+ border-radius: 0;
+ font-size: var(--rst-fs-xs);
+ font-weight: 700;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+/* ── The counterfoil ──────────────────────────────────────────────────
+ The server's half of the ticket: everything above the tear, printed on
+ the tint block the way a counterfoil is. It is the only place in the
+ app that changes colour without changing meaning, and the meaning it
+ carries is "this side is theirs". */
+
+[data-counterfoil] {
+ background: var(--rst-surface-2);
+ padding: var(--rst-sp-4);
+}
+
+[data-counterfoil] > :first-child {
+ margin-block-start: 0;
+}
+
+[data-counterfoil] > :last-child {
+ margin-block-end: 0;
+}
+
/* ── The one authored moment ──────────────────────────────────────────
The world's native motion is the tear. So the single piece of motion
in this app is the vault opening: the sealed half below the line is
diff --git a/internal/contra/static/theme.css b/internal/contra/static/theme.css
index f70b401..552e1bf 100644
--- a/internal/contra/static/theme.css
+++ b/internal/contra/static/theme.css
@@ -122,6 +122,8 @@
--contra-ink-2 on --rst-surface-2 3.57 3:1 numbering, large
--contra-ink-2 on --contra-stock-2 3.76 3:1 numbering, large
--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
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
@@ -155,6 +157,8 @@
--contra-ink-2 on --rst-surface-2 6.06 3:1 numbering, large
--contra-ink-2 on --contra-stock-2 5.07 3:1 numbering, large
--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
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/index.html b/internal/contra/templates/index.html
index 25e35ef..a149ef6 100644
--- a/internal/contra/templates/index.html
+++ b/internal/contra/templates/index.html
@@ -17,6 +17,11 @@ apart from anyone else, and does not know your email address. If it is stolen
tomorrow, the thief gets a pile of ciphertext and the times of day you
connected.</p>
+{{/* A condition of carriage, so it prints as one: the stamp device,
+ which is where this design is allowed to spend its second colour.
+ The words are unchanged — copy is a reviewed artifact here and this
+ is a change of print, not of language. */}}
+<section data-notice>
<h2>This is a preview, not a product</h2>
<p>You can create an account, sign in and keep items here, and all of it is
@@ -29,6 +34,7 @@ that data on this instance survives the next deploy.</p>
Emergency Kit still prints as text on screen rather than the two PDFs it will
be, and a browser that has never seen your Account Key cannot yet be given
it — so treat every account here as one you could lose.</p>
+</section>
<p>
{{if .SignedIn}}<a href="/vault">Open your vault</a>{{else}}<a href="/signup">Create an account</a> &middot;
diff --git a/internal/contra/templates/vault.html b/internal/contra/templates/vault.html
index 821696c..54976b7 100644
--- a/internal/contra/templates/vault.html
+++ b/internal/contra/templates/vault.html
@@ -5,6 +5,13 @@
{{define "content"}}
<h1>Your vault</h1>
+{{/* The counterfoil — the server's half of the ticket, and everything on
+ it is something the server already holds: that you have a session,
+ which vaults you can reach, and their opaque ids. It prints on the
+ tint block because that is what a counterfoil is, and it ends at the
+ perforation below. */}}
+<div data-counterfoil>
+
{{/* Signing in is not unlocking. The session proved who you are; the keys
come from a wrap this server cannot open, so the page asks again and
does the work in the browser. */}}
@@ -28,6 +35,8 @@
<select id="vault-picker" data-vault-picker></select>
</p>
+</div>
+
{{/* The tear-line, and the only one in the app. Everything above it is
what this server holds and could hand to anyone who stole it: a
public key, opaque ids, counts, the times of day you connected.