oficina / fichas Public

Domain is a forgiving web address; tapping a field no longer zooms

Paul, 2026-09-30: "Tapping into an input shouldn't zoom the view. 'Domain'
field should be URL type and very forgiving whether I type the protocol or
not."

The zoom. iOS Safari zooms into any form control under 16px, and
rastrillo's base size is 14px, so every tap into a field zoomed. Inputs,
selects and textareas are now at least 16px on touch screens. Not
maximum-scale=1 in the viewport tag, which stops the zoom by switching off
pinch-zoom for everyone — an accessibility failure. The rule is wrapped in
:is() so select and textarea take its (0,3,1) specificity; as a plain list
they lost to rastrillo's [rst-textarea] and stayed at 14px, which the new
browser test caught. That test was itself wrong twice before it was right:
SetEmulatedMedia's "pointer" feature is not emulated by this Chromium, so
it measured desktop styles while claiming a phone's — it now asserts the
media query matches before measuring, uses touch emulation and a reload,
and fails with the rule removed. Every rastrillo app inherits the 14px
controls, so this belongs upstream too, as does rastrillo's date input
not inheriting the page font (13.3px even on desktop).

Web addresses. A new kind, and the shipped Domain field moves to it. Not
<input type="url">: browsers reject "example.com" without a scheme, which
is the opposite of forgiving. The control is text with inputmode="url"
(the URL keyboard, no autocapitalise or autocorrect), and the forgiveness
is server-side: "example.com", "WWW.Example.com/", "http://…" and
"https://…" are all accepted and stored as a lowercase http(s) URL; shown
without the scheme; linked with noopener. Anything a browser would execute
or that is not a web address — javascript:, mailto:, data:, credentials in
the URL, a host with no dot — is refused with a message beside the field.
Findable addresses compare by host without "www." or the scheme, so one
company typed three ways is one fingerprint.

Moving a shipped field to a new kind is exactly what Seed refuses, and it
still does for anything undeclared. kindUpgrades lists the one declared
transition, Text → Web address; on boot each existing value is
re-normalised, re-sealed if it changed, re-fingerprinted, and each card
whose value changed gets a revision and an event from the app as itself.
Idempotent: a second boot changes nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 3e515f16e8b2ca21a8ea3a1fe7f530d770660eeb parent d51ad89
17 files changed, +429 −19
  • internal/fichas/catalogue.go +86 −1
  • internal/fichas/handlers_schema.go +1 −1
  • internal/fichas/print.go +2 −0
  • internal/fichas/read.go +7 −4
  • internal/fichas/render.go +12 −10
  • internal/fichas/schema.go +5 −1
  • internal/fichas/static/fichas.css +16 −0
  • internal/fichas/store.go +8 −1
  • internal/fichas/templates/card_layout.html +1 −0
  • internal/fichas/templates/card_show.html +6 −1
  • internal/fichas/templates/field_new.html +1 −0
  • internal/fichas/templates/index.html +2 −0
  • internal/fichas/templates/partials.html +8 −0
  • internal/fichas/urls.go +97 −0
  • internal/fichas/urls_test.go +122 −0
  • internal/fichastest/browser_test.go +31 −0
  • internal/fichastest/designer_test.go +24 −0
diff --git a/internal/fichas/catalogue.go b/internal/fichas/catalogue.go
index 22acfad..c302224 100644
--- a/internal/fichas/catalogue.go
+++ b/internal/fichas/catalogue.go
@@ -104,7 +104,9 @@ var catalogue = []seedType{
// worth answering without the key — "is this the same
// company", "do we already have this contact".
{Key: FieldName, Label: "Name", Kind: KindText, Tier: TierPrint},
- {Key: FieldDomain, Label: "Domain", Kind: KindText, Tier: TierPrint},
+ // A web address since 2026-09-30 (kindUpgrades): forgiving about
+ // the scheme, stored as https://, compared by host.
+ {Key: FieldDomain, Label: "Domain", Kind: KindURL, Tier: TierPrint},
{Key: FieldEmail, Label: "Email", Kind: KindText, Tier: TierPrint},
{Key: FieldPhone, Label: "Phone", Kind: KindText, Tier: TierPrint},
@@ -229,6 +231,9 @@ func seedField_(tx *gorm.DB, s *seal.Sealer, cardTypeID int64, ordinal int, f se
// Label and ordinal may drift freely — they are presentation.
// Kind and Tier may not: both describe how the values already in
// the database were written.
+ if fd.Kind != f.Kind && fd.Tier == f.Tier && kindUpgrades[f.Key] == [2]string{fd.Kind, f.Kind} {
+ return upgradeKind(tx, s, fd, f.Kind)
+ }
if fd.Kind != f.Kind || fd.Tier != f.Tier {
return fmt.Errorf(
"seed: %s is stored as kind=%s tier=%s but the catalogue now says kind=%s tier=%s; "+
@@ -285,3 +290,83 @@ func openOptions(s *seal.Sealer, sealed []byte) ([]string, error) {
}
return out, nil
}
+
+// kindUpgrades are the kind changes Seed is allowed to apply to a shipped
+// field, as {from, to}. Every other change still refuses to boot: a kind
+// describes how the values already stored were written, and changing it
+// silently would leave the definition lying about them.
+//
+// An upgrade is listed here only when every existing value can be carried
+// across by re-normalising it — Text to Web address is: a value is either an
+// address already, or it is kept exactly as it was and flagged the next time
+// someone edits it.
+var kindUpgrades = map[string][2]string{
+ FieldDomain: {KindText, KindURL},
+}
+
+// upgradeKind moves a shipped field to a new kind and carries its values
+// across: each is re-normalised, re-sealed if it changed, and re-printed
+// under the new kind's canonical form, and each card whose value changed
+// gets an event and a revision — the ruling that every change to every
+// object is logged applies to a change the app makes as much as to one a
+// person does.
+func upgradeKind(tx *gorm.DB, s *seal.Sealer, fd FieldDef, to string) error {
+ next := fd
+ next.Kind = to
+ var values []CardValue
+ if err := tx.Where("field_def_id = ?", fd.ID).Find(&values).Error; err != nil {
+ return fmt.Errorf("seed: loading %s values to upgrade: %w", fd.Key, err)
+ }
+ for _, v := range values {
+ raw, err := s.Open(seal.PurposeValue, v.SealedValue)
+ if err != nil {
+ return fmt.Errorf("seed: opening a %s value: %w", fd.Key, err)
+ }
+ before := string(raw)
+ after := before
+ if to == KindURL {
+ if n, err := normaliseURL(before); err == nil {
+ after = n
+ }
+ }
+ set := map[string]any{}
+ if after != before {
+ sealed, err := s.Seal(seal.PurposeValue, []byte(after))
+ if err != nil {
+ return err
+ }
+ set["sealed_value"] = sealed
+ }
+ if fd.Tier == TierPrint {
+ pr, err := printFor(s, next, after)
+ if err != nil {
+ return err
+ }
+ set["print"] = pr
+ }
+ if len(set) > 0 {
+ if err := tx.Model(&CardValue{}).Where("card_id = ? AND field_def_id = ?", v.CardID, v.FieldDefID).Updates(set).Error; err != nil {
+ return err
+ }
+ }
+ if after != before {
+ var card Card
+ if err := tx.First(&card, v.CardID).Error; err != nil {
+ return err
+ }
+ if err := bumpRevision(tx, &card, nil); err != nil {
+ return err
+ }
+ if err := logEvent(tx, s, EventUpdated, ObjectCard, card.ID, 0, card.Revision,
+ AppBecause(0, "field kind upgrade"), []int64{fd.ID},
+ []change{{Field: fd.Key, Before: before, After: after}}); err != nil {
+ return err
+ }
+ }
+ }
+ if err := tx.Model(&FieldDef{}).Where("id = ?", fd.ID).Update("kind", to).Error; err != nil {
+ return err
+ }
+ return logEvent(tx, s, EventUpdated, ObjectField, fd.ID, 0, 0, AppBecause(0, "field kind upgrade"), nil,
+ []change{{Field: "kind", Before: fd.Kind, After: to}})
+}
diff --git a/internal/fichas/handlers_schema.go b/internal/fichas/handlers_schema.go
index 75cb90e..371316f 100644
--- a/internal/fichas/handlers_schema.go
+++ b/internal/fichas/handlers_schema.go
@@ -14,7 +14,7 @@ import (
// kindWords names a kind for a person.
var kindWords = map[string]string{
KindText: "Text", KindLongText: "Long text", KindNumber: "Number",
- KindDate: "Date", KindEnum: "Choice", KindRef: "Link",
+ KindDate: "Date", KindEnum: "Choice", KindRef: "Link", KindURL: "Web address",
}
// FieldRow is one line of a type's fields page.
diff --git a/internal/fichas/print.go b/internal/fichas/print.go
index 9a63820..dab92fd 100644
--- a/internal/fichas/print.go
+++ b/internal/fichas/print.go
@@ -46,6 +46,8 @@ func canonical(kind, v string) string {
switch kind {
case KindNumber:
return v
+ case KindURL:
+ return canonicalURL(v)
case KindText, KindEnum, KindDate, KindRef:
// Phone numbers arrive as text. Keeping digits and a leading +
// makes "+353 1 555 0100" and "+35315550100" one value; anything
diff --git a/internal/fichas/read.go b/internal/fichas/read.go
index 0266835..934b1a3 100644
--- a/internal/fichas/read.go
+++ b/internal/fichas/read.go
@@ -383,13 +383,16 @@ func (c CardView) DetailItems() []map[string]any {
if c.IsTitle(f.Def.Key) || isLong(f.Def) {
continue
}
- v := f.Value
- if v == "" {
+ v, href := f.Value, ""
+ switch {
+ case v == "":
v = "—"
- } else if f.Def.Kind == KindDate {
+ case f.Def.Kind == KindDate:
v = displayDate(v)
+ case f.Def.Kind == KindURL:
+ href, v = safeHref(v), displayURL(v)
}
- out = append(out, map[string]any{"Label": f.Label, "Value": v})
+ out = append(out, map[string]any{"Label": f.Label, "Value": v, "Href": href})
}
return out
}
diff --git a/internal/fichas/render.go b/internal/fichas/render.go
index d7c7b3d..8f78862 100644
--- a/internal/fichas/render.go
+++ b/internal/fichas/render.go
@@ -44,16 +44,18 @@ func init() {
t := template.Must(template.New("layout").
Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))).
Funcs(template.FuncMap{
- "asset": assets.Path,
- "options": selectOptions,
- "ago": ago,
- "dateAgo": dateAgo,
- "stageTone": stageTone,
- "isLong": isLong,
- "lower": lower,
- "commas": commas,
- "errFor": errFor,
- "yesNo": yesNo,
+ "asset": assets.Path,
+ "options": selectOptions,
+ "ago": ago,
+ "dateAgo": dateAgo,
+ "stageTone": stageTone,
+ "isLong": isLong,
+ "lower": lower,
+ "commas": commas,
+ "errFor": errFor,
+ "yesNo": yesNo,
+ "displayURL": displayURL,
+ "safeHref": safeHref,
}).
ParseFS(ui.Templates(), "*.html"))
if name == "signin" {
diff --git a/internal/fichas/schema.go b/internal/fichas/schema.go
index 3788629..dc36bdb 100644
--- a/internal/fichas/schema.go
+++ b/internal/fichas/schema.go
@@ -67,7 +67,7 @@ func (e FormError) Error() string {
// userKinds are the kinds the designer offers. KindRef ("link to another
// card") needs a picker and waits; offering it without one would store ids
// typed by hand.
-var userKinds = map[string]bool{KindText: true, KindLongText: true, KindNumber: true, KindDate: true, KindEnum: true}
+var userKinds = map[string]bool{KindText: true, KindLongText: true, KindNumber: true, KindDate: true, KindEnum: true, KindURL: true}
func (spec FieldSpec) validate() FormError {
e := FormError{}
@@ -385,6 +385,10 @@ func validateValues(defs map[string]FieldDef, values map[string]string) FormErro
if _, err := time.Parse("2006-01-02", v); err != nil {
e[key] = "Enter a date."
}
+ case KindURL:
+ if _, err := normaliseURL(v); err != nil {
+ e[key] = "Enter a web address, like example.com."
+ }
case KindEnum:
// The options are sealed and checked by the caller, which has
// them open; see optionsError.
diff --git a/internal/fichas/static/fichas.css b/internal/fichas/static/fichas.css
index 906162f..8d59ea7 100644
--- a/internal/fichas/static/fichas.css
+++ b/internal/fichas/static/fichas.css
@@ -242,3 +242,19 @@
/* ---- A live region is a wrapper for swapping, not for layout: the filter
menus inside the list bar stay flex items of the bar. */
#list-filters { display: contents; }
+
+/* ---- No zoom on tap. iOS Safari zooms into any form control whose text
+ is under 16px, and rastrillo's base size is 14px, so every tap into a
+ field zoomed the page. The fix is the size, on touch screens only.
+ `maximum-scale=1` in the viewport tag would also stop it — by switching
+ off pinch-zoom for everyone, which is an accessibility failure. Every
+ rastrillo app inherits the same 14px; this belongs upstream too. */
+@media (pointer: coarse) {
+ /* :is() takes the specificity of its strongest argument (0,3,1) for all
+ three. Written as a plain list, `select` and `textarea` were (0,0,1)
+ and lost to rastrillo's [rst-textarea] and friends — the browser test
+ caught them still at 14px. */
+ :is(input:not([type="checkbox"]):not([type="radio"]):not([type="range"]), select, textarea) {
+ font-size: max(16px, 1em);
+ }
+}
diff --git a/internal/fichas/store.go b/internal/fichas/store.go
index 475b881..481c657 100644
--- a/internal/fichas/store.go
+++ b/internal/fichas/store.go
@@ -176,8 +176,15 @@ func writeValues(tx *gorm.DB, s *seal.Sealer, cardID int64, defs map[string]Fiel
// as "1200", or it would sort as text and never compare as a number.
normalised := make(map[string]string, len(values))
for k, v := range values {
- if defs[k].Kind == KindNumber {
+ switch defs[k].Kind {
+ case KindNumber:
v = strings.ReplaceAll(strings.TrimSpace(v), ",", "")
+ case KindURL:
+ // A value that will not normalise is left as typed so that
+ // validateValues can say what is wrong with it.
+ if n, err := normaliseURL(v); err == nil {
+ v = n
+ }
}
normalised[k] = v
}
diff --git a/internal/fichas/templates/card_layout.html b/internal/fichas/templates/card_layout.html
index 954f7e8..5b656f2 100644
--- a/internal/fichas/templates/card_layout.html
+++ b/internal/fichas/templates/card_layout.html
@@ -25,6 +25,7 @@
{{if eq .Kind "call"}}<p fichas-card-quote>{{.Value}}</p>
{{else if eq .Kind "date"}}<p fichas-meta>{{.Label}} <time datetime="{{.Date}}" title="{{.Date}}">{{dateAgo .Date}}</time></p>
{{else if eq .Kind "longtext"}}<p fichas-meta fichas-clamp>{{.Value}}</p>
+ {{else if eq .Kind "url"}}<p fichas-meta>{{displayURL .Value}}</p>
{{else}}<p fichas-meta>{{.Value}}</p>{{end}}
{{end}}
</article></li></ul>
diff --git a/internal/fichas/templates/card_show.html b/internal/fichas/templates/card_show.html
index f5845b6..d89770b 100644
--- a/internal/fichas/templates/card_show.html
+++ b/internal/fichas/templates/card_show.html
@@ -31,7 +31,12 @@
<aside>
<div rst-box>
<div rst-box-head><h2>Details</h2></div>
- {{template "detail-list" (dict "Items" $c.DetailItems)}}
+ {{/* rastrillo's detail-list markup, written out because its partial
+ has no link slot and a web address should be one. */}}
+ <dl rst-detail>
+ {{range $c.DetailItems}}<dt>{{.Label}}</dt><dd>{{with .Href}}<a fichas-link href="{{.}}" target="_blank" rel="noopener noreferrer">{{end}}{{.Value}}{{if .Href}}</a>{{end}}</dd>
+ {{end}}
+ </dl>
</div>
{{range $c.LongFields}}
<div rst-box>
diff --git a/internal/fichas/templates/field_new.html b/internal/fichas/templates/field_new.html
index 53f21dd..1cbedab 100644
--- a/internal/fichas/templates/field_new.html
+++ b/internal/fichas/templates/field_new.html
@@ -10,6 +10,7 @@
(dict "Value" "longtext" "Title" "Long text" "Desc" "Paragraphs." "Checked" (eq (index .Form "kind") "longtext"))
(dict "Value" "number" "Title" "Number" "Checked" (eq (index .Form "kind") "number"))
(dict "Value" "date" "Title" "Date" "Checked" (eq (index .Form "kind") "date"))
+ (dict "Value" "url" "Title" "Web address" "Desc" "A website or a link. Type it with or without https://." "Checked" (eq (index .Form "kind") "url"))
(dict "Value" "enum" "Title" "Choice" "Desc" "One of a list you set — like Stage." "Checked" (eq (index .Form "kind") "enum"))))}}
{{with errFor .Errors "kind"}}<p rst-field-error role="alert">{{.}}</p>{{end}}
{{template "field-textarea" (dict "Name" "options" "Label" "Options, for a Choice" "Value" (index .Form "options") "Rows" 4 "Hint" "one per line" "Error" (errFor .Errors "options"))}}
diff --git a/internal/fichas/templates/index.html b/internal/fichas/templates/index.html
index 390c47c..ac2e6b6 100644
--- a/internal/fichas/templates/index.html
+++ b/internal/fichas/templates/index.html
@@ -35,6 +35,7 @@
{{else if not $v}}<td><span fichas-empty-cell>—</span></td>
{{else if eq $f.Def.Kind "enum"}}<td>{{template "status-pill" (dict "Label" $v "Tone" (stageTone $v))}}</td>
{{else if eq $f.Def.Kind "date"}}<td><time datetime="{{$v}}" title="{{$v}}">{{dateAgo $v}}</time></td>
+ {{else if eq $f.Def.Kind "url"}}<td>{{with safeHref $v}}<a fichas-link href="{{.}}" target="_blank" rel="noopener noreferrer">{{displayURL $v}}</a>{{else}}{{$v}}{{end}}</td>
{{else}}<td>{{$v}}</td>{{end}}
{{end}}{{end}}
<td>{{with $c.Latest}}<time datetime="{{.Entry.CreatedAt.Format "2006-01-02"}}" title="{{.Entry.CreatedAt.Format "2 January 2006, 15:04"}}">{{ago .Entry.CreatedAt}}</time>{{else}}<span fichas-empty-cell>never</span>{{end}}</td>
@@ -52,6 +53,7 @@
{{if eq .Kind "call"}}<p fichas-card-quote>{{.Value}}</p>
{{else if eq .Kind "date"}}<p fichas-meta>{{.Label}} <time datetime="{{.Date}}" title="{{.Date}}">{{dateAgo .Date}}</time></p>
{{else if eq .Kind "longtext"}}<p fichas-meta fichas-clamp>{{.Value}}</p>
+ {{else if eq .Kind "url"}}<p fichas-meta>{{displayURL .Value}}</p>
{{else}}<p fichas-meta>{{.Value}}</p>{{end}}
{{end}}
</article></li>
diff --git a/internal/fichas/templates/partials.html b/internal/fichas/templates/partials.html
index 7f74b26..a31c0dd 100644
--- a/internal/fichas/templates/partials.html
+++ b/internal/fichas/templates/partials.html
@@ -9,6 +9,14 @@
{{template "field-select" (dict "ID" $f.Def.Key "Name" $f.Def.Key "Label" $f.Label "Options" (options $f.Options $f.Value) "Error" .Error)}}
{{- else if eq $f.Def.Kind "date" -}}
{{template "field-date" (dict "Name" $f.Def.Key "Label" $f.Label "Value" $f.Value "Error" .Error)}}
+{{- else if eq $f.Def.Kind "url" -}}
+ {{/* Not type="url": browsers reject "example.com" without a scheme, and
+ Paul asked for forgiving. text + inputmode="url" gets the URL keyboard
+ with none of the validation; the server normalises (urls.go). The
+ markup mirrors rastrillo's field partial, which has no inputmode key. */}}
+ <div rst-field><label rst-field-label for="{{$f.Def.Key}}">{{$f.Label}}</label>
+ <input rst-input type="text" inputmode="url" autocomplete="url" autocapitalize="none" autocorrect="off" spellcheck="false" id="{{$f.Def.Key}}" name="{{$f.Def.Key}}"{{with $f.Value}} value="{{displayURL .}}"{{end}} placeholder="example.com"{{if .Autofocus}} autofocus{{end}}{{if .Error}} aria-invalid="true" aria-describedby="{{$f.Def.Key}}-error"{{end}}>
+ {{if .Error}}<p rst-field-error id="{{$f.Def.Key}}-error" role="alert">{{.Error}}</p>{{end}}</div>
{{- else if isLong $f.Def -}}
{{template "field-textarea" (dict "Name" $f.Def.Key "Label" $f.Label "Value" $f.Value "Rows" 4 "Error" .Error)}}
{{- else -}}
diff --git a/internal/fichas/urls.go b/internal/fichas/urls.go
new file mode 100644
index 0000000..214bd9f
--- /dev/null
+++ b/internal/fichas/urls.go
@@ -0,0 +1,97 @@
+package fichas
+
+import (
+ "errors"
+ "net/url"
+ "strings"
+ "unicode"
+)
+
+// KindURL is a web address. Paul, 2026-09-30: the Domain field "should be
+// URL type and very forgiving whether I type the protocol or not."
+//
+// Forgiving is exactly what <input type="url"> is not: browsers reject
+// "example.com" without a scheme. So the control is a plain text input with
+// the URL keyboard (inputmode="url"), and all the forgiveness is here, on the
+// server, where it applies to every way a value can arrive.
+const KindURL = "url"
+
+var errNotAURL = errors.New("not a web address")
+
+// normaliseURL accepts what a person types for a web address and returns the
+// stored form: an http(s) URL with a lowercase host and no bare trailing
+// slash. "example.com", "WWW.Example.com/", "https://example.com" and
+// "http://example.com/pricing" are all fine. Anything whose scheme is not
+// http or https — javascript:, mailto:, data: — is refused, because the
+// stored value becomes a link.
+func normaliseURL(v string) (string, error) {
+ v = strings.TrimSpace(v)
+ if v == "" {
+ return "", nil
+ }
+ if strings.IndexFunc(v, unicode.IsSpace) >= 0 {
+ return "", errNotAURL
+ }
+ if !strings.Contains(v, "://") {
+ // "mailto:x" and "javascript:x" have no "://" either; with https://
+ // in front the part before the colon parses as a host with a
+ // non-numeric port, and Parse refuses it below.
+ v = "https://" + v
+ }
+ u, err := url.Parse(v)
+ if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil {
+ return "", errNotAURL
+ }
+ host := strings.ToLower(u.Hostname())
+ if host == "" || !strings.Contains(host, ".") || strings.HasPrefix(host, ".") || strings.HasSuffix(host, ".") {
+ return "", errNotAURL
+ }
+ for _, r := range host {
+ if !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '.') {
+ return "", errNotAURL
+ }
+ }
+ u.Scheme = strings.ToLower(u.Scheme)
+ if p := u.Port(); p != "" {
+ u.Host = host + ":" + p
+ } else {
+ u.Host = host
+ }
+ if u.Path == "/" && u.RawQuery == "" && u.Fragment == "" {
+ u.Path = ""
+ }
+ return u.String(), nil
+}
+
+// displayURL is how a stored address reads: no scheme, no bare trailing
+// slash — "brightwater.example", not "https://brightwater.example/".
+func displayURL(v string) string {
+ s := strings.TrimPrefix(strings.TrimPrefix(v, "https://"), "http://")
+ return strings.TrimSuffix(s, "/")
+}
+
+// safeHref is the stored address as a link target, or "" if it is not an
+// http(s) URL — a value that predates validation, say. Never anything a
+// browser would execute.
+func safeHref(v string) string {
+ u, err := url.Parse(v)
+ if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
+ return ""
+ }
+ return u.String()
+}
+
+// canonicalURL is the identity of an address for duplicate-checking: host
+// without "www.", plus path, no scheme, no trailing slash, lowercase. So
+// "https://www.Brightwater.example/" and "brightwater.example" are the same
+// company. Deliberately looser than the stored form, which keeps what was
+// typed.
+func canonicalURL(v string) string {
+ n, err := normaliseURL(v)
+ if err != nil {
+ return strings.ToLower(strings.TrimSpace(v))
+ }
+ u, _ := url.Parse(n)
+ host := strings.TrimPrefix(strings.ToLower(u.Hostname()), "www.")
+ return strings.TrimSuffix(host+strings.ToLower(u.EscapedPath()), "/")
+}
diff --git a/internal/fichas/urls_test.go b/internal/fichas/urls_test.go
new file mode 100644
index 0000000..196bce6
--- /dev/null
+++ b/internal/fichas/urls_test.go
@@ -0,0 +1,122 @@
+package fichas
+
+import (
+ "testing"
+
+ "amadan.net/oficina/fichas/internal/fichas/seal"
+)
+
+// "Very forgiving whether I type the protocol or not."
+func TestNormaliseURLForgives(t *testing.T) {
+ for in, want := range map[string]string{
+ "brightwater.example": "https://brightwater.example",
+ " Brightwater.Example ": "https://brightwater.example",
+ "www.brightwater.example/": "https://www.brightwater.example",
+ "https://brightwater.example": "https://brightwater.example",
+ "HTTPS://BRIGHTWATER.EXAMPLE/": "https://brightwater.example",
+ "http://brightwater.example": "http://brightwater.example",
+ "brightwater.example/pricing": "https://brightwater.example/pricing",
+ "brightwater.example:8443/x?y=1": "https://brightwater.example:8443/x?y=1",
+ "": "",
+ } {
+ got, err := normaliseURL(in)
+ if err != nil || got != want {
+ t.Errorf("normaliseURL(%q) = %q, %v; want %q", in, got, err, want)
+ }
+ }
+}
+
+// The stored value becomes a link, so anything a browser would execute or
+// that is not a web address is refused.
+func TestNormaliseURLRefuses(t *testing.T) {
+ for _, in := range []string{
+ "javascript:alert(1)", "mailto:ana@example.com", "data:text/html,x",
+ "ftp://files.example", "not a domain", "localhost", "brightwater",
+ "https://user:pass@brightwater.example", ".example", "example.",
+ "https://bright<water>.example",
+ } {
+ if got, err := normaliseURL(in); err == nil {
+ t.Errorf("normaliseURL(%q) accepted it as %q", in, got)
+ }
+ }
+}
+
+func TestDisplayAndHref(t *testing.T) {
+ if got := displayURL("https://brightwater.example/pricing/"); got != "brightwater.example/pricing" {
+ t.Errorf("displayURL = %q", got)
+ }
+ if got := safeHref("javascript:alert(1)"); got != "" {
+ t.Errorf("safeHref let a script URL through: %q", got)
+ }
+ if got := safeHref("https://brightwater.example"); got != "https://brightwater.example" {
+ t.Errorf("safeHref = %q", got)
+ }
+}
+
+// Findable addresses dedupe by host: the same company typed three ways is
+// one print.
+func TestURLPrintsMatchAcrossSpellings(t *testing.T) {
+ s := testSealer(t)
+ fd := FieldDef{Key: FieldDomain, Kind: KindURL, Tier: TierPrint}
+ a, _ := printFor(s, fd, "brightwater.example")
+ b, _ := printFor(s, fd, "https://www.Brightwater.example/")
+ c, _ := printFor(s, fd, "http://brightwater.example")
+ if a != b || b != c {
+ t.Fatal("one company's address prints differently depending on how it was typed")
+ }
+ d, _ := printFor(s, fd, "brightwater.example/other-page")
+ if a == d {
+ t.Fatal("two different pages print the same")
+ }
+}
+
+// The shipped Domain field was Text. Seed upgrades it on boot, carrying the
+// values across and logging each change — and refuses any undeclared kind
+// change as before.
+func TestSeedUpgradesDomainToAWebAddress(t *testing.T) {
+ g, s := seeded(t)
+ card, err := CreateCard(ctx(), g, s, TypeCustomer, Person(1), map[string]string{FieldName: "Brightwater Labs"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Recreate the old world: Domain as Text, with a value typed the old way.
+ var fd FieldDef
+ g.Where("key = ?", FieldDomain).First(&fd)
+ g.Model(&FieldDef{}).Where("id = ?", fd.ID).Update("kind", KindText)
+ old := fd
+ old.Kind = KindText
+ sealed, _ := s.Seal(seal.PurposeValue, []byte("Brightwater.example/"))
+ pr, _ := printFor(s, old, "Brightwater.example/")
+ g.Create(&CardValue{CardID: card.ID, FieldDefID: fd.ID, SealedValue: sealed, Print: pr})
+
+ if err := Seed(g, s); err != nil {
+ t.Fatalf("Seed refused the declared upgrade: %v", err)
+ }
+ g.Where("key = ?", FieldDomain).First(&fd)
+ if fd.Kind != KindURL {
+ t.Fatalf("Domain is still %s", fd.Kind)
+ }
+ var v CardValue
+ g.Where("card_id = ? AND field_def_id = ?", card.ID, fd.ID).First(&v)
+ raw, _ := s.Open(seal.PurposeValue, v.SealedValue)
+ if string(raw) != "https://brightwater.example" {
+ t.Fatalf("the upgraded value is %q", raw)
+ }
+ want, _ := printFor(s, fd, "brightwater.example")
+ if v.Print != want {
+ t.Fatal("the value's print was not recomputed under the new kind")
+ }
+ ev := events(t, g, ObjectCard, card.ID)
+ last := ev[len(ev)-1]
+ if last.Kind != EventUpdated || last.ActorID != nil || last.ActorReason != "field kind upgrade" {
+ t.Fatalf("the carried-across value was not logged as the app's own change: %+v", last)
+ }
+ // And it is idempotent: a second boot changes nothing.
+ n := len(ev)
+ if err := Seed(g, s); err != nil {
+ t.Fatalf("second Seed: %v", err)
+ }
+ if len(events(t, g, ObjectCard, card.ID)) != n {
+ t.Fatal("a second boot upgraded again")
+ }
+}
diff --git a/internal/fichastest/browser_test.go b/internal/fichastest/browser_test.go
index d87d020..4643f5e 100644
--- a/internal/fichastest/browser_test.go
+++ b/internal/fichastest/browser_test.go
@@ -29,6 +29,7 @@ import (
"github.com/carlosframework/rastrillo/db"
"github.com/carlosframework/rastrillo/harness"
+ "github.com/chromedp/cdproto/emulation"
"github.com/chromedp/chromedp"
fichas "amadan.net/oficina/fichas/internal/fichas"
@@ -186,3 +187,33 @@ func TestAnExpiredSessionIsNotADeadEnd(t *testing.T) {
t.Fatal("a non-list answer left the page where it was; the search box is a dead end")
}
}
+
+// TestNoZoomOnTap: iOS Safari zooms into any form control under 16px. On a
+// touch screen every text input must be at least 16px. Emulated as
+// pointer: coarse, which is how the CSS decides.
+func TestNoZoomOnTap(t *testing.T) {
+ rig := browserApp(t, nil)
+ var smallest float64
+ var coarse bool
+ rig.Run(
+ chromedp.Navigate(rig.Origin+"/cards/new"),
+ chromedp.WaitVisible(`input[name="f_name"]`),
+ // Touch emulation, then a reload so the page re-evaluates its media
+ // queries. The first version used SetEmulatedMedia with a "pointer"
+ // feature, which this Chromium does not emulate — the test measured
+ // desktop styles while claiming to measure a phone's, and passing
+ // or failing told us nothing. The coarse check below is what caught
+ // that.
+ emulation.SetTouchEmulationEnabled(true).WithMaxTouchPoints(5),
+ chromedp.Reload(),
+ chromedp.WaitVisible(`input[name="f_name"]`),
+ chromedp.Evaluate(`matchMedia('(pointer: coarse)').matches`, &coarse),
+ chromedp.Evaluate(`Math.min(...[...document.querySelectorAll('input:not([type=checkbox]):not([type=radio]):not([type=hidden]), select, textarea')].map(e => parseFloat(getComputedStyle(e).fontSize)))`, &smallest),
+ )
+ if !coarse {
+ t.Fatal("the touch-screen emulation did not apply; this test would measure desktop styles")
+ }
+ if smallest < 16 {
+ t.Fatalf("a form control is %.1fpx on a touch screen; iOS will zoom when it is tapped", smallest)
+ }
+}
diff --git a/internal/fichastest/designer_test.go b/internal/fichastest/designer_test.go
index 945f4d9..09b8e05 100644
--- a/internal/fichastest/designer_test.go
+++ b/internal/fichastest/designer_test.go
@@ -115,3 +115,27 @@ func TestABadValueIsExplainedNotA500(t *testing.T) {
t.Error("the form lost the name that was typed")
}
}
+
+func TestTheDomainIsALinkAndForgiving(t *testing.T) {
+ h := newApp(t)
+ rec := post(t, h, "/cards", url.Values{"f_name": {"Brightwater Labs"}, "f_domain": {"Brightwater.example/"}})
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("a domain without https://: status %d\n%s", rec.Code, rec.Body.String())
+ }
+ show := get(t, h, rec.Header().Get("Location")).Body.String()
+ if !strings.Contains(show, `href="https://brightwater.example"`) || !strings.Contains(show, ">brightwater.example</a>") {
+ t.Error("the read view does not link the address, or shows the protocol")
+ }
+ if !strings.Contains(get(t, h, "/").Body.String(), `href="https://brightwater.example"`) {
+ t.Error("the table does not link the address")
+ }
+ edit := get(t, h, rec.Header().Get("Location")+"/edit").Body.String()
+ if !strings.Contains(edit, `inputmode="url"`) || strings.Contains(edit, `type="url"`) {
+ t.Error("the input is not a forgiving URL field")
+ }
+
+ bad := post(t, h, "/cards", url.Values{"f_name": {"X"}, "f_domain": {"javascript:alert(1)"}})
+ if bad.Code != http.StatusUnprocessableEntity || !strings.Contains(bad.Body.String(), "Enter a web address") {
+ t.Errorf("a script URL: status %d, want 422 with a message", bad.Code)
+ }
+}