docs(standard): screenshot-free UI documentation standard + UI-label gate
The planned Playwright screenshot docs (SCREENSHOT_AUTOMATION.md) are replaced by a text standard: screenshots rot silently and cannot be gate-checked, while this UI's labels are server-rendered templ literals that can. Layers: pattern-first prose (concepts survive redesigns) > capability inventory > label->outcome flows > quoted labels > generated references where a registry exists (filters/fields.md precedent). The filter docs stay hands-off exemplars; user-doc tone is plain language by contract, dev/admin tone explicitly deferred. writing-ui-docs.md is the standard (page template, findability notes, tone-by-audience, process rule: a UI change is not done until the docs audit runs). ui-controls.md is the canonical control-label glossary the docs quote from; TestUIControlLabels greps every entry against templates/*.templ (source: ts override; also:-fragments for Alpine-composed labels like the Match/Group header) so renames fail make test in the same commit as the rename. Gate proven both ways: a planted bogus label failed; restored set passes.
This commit is contained in:
1 parent
fbdd4ab9f9
commit
e07301f74a
3 files changed
+498
No files matched your search
@@ -0,0 +1,166 @@
|
|||||||
|
<!-- Gate-checked by TestUIControlLabels (internal/docs/ui_labels_test.go):
|
||||||
|
every `controls` entry must exist as a literal in templates/*.templ
|
||||||
|
(or web/src for source: ts). Renames fail make test. Entry text is the
|
||||||
|
canonical spelling cited across the docs. -->
|
||||||
|
|
||||||
|
# UI controls glossary
|
||||||
|
|
||||||
|
The canonical spellings for controls cited across the UI documentation. Docs
|
||||||
|
quote the label exactly as this page lists it — one entry per control; two
|
||||||
|
spellings of one control is a defect. The `controls` block is machine-read:
|
||||||
|
when a doc first names a control, add its entry in the same commit; when a
|
||||||
|
rename lands, update the entry in the same commit and the gate goes green
|
||||||
|
again.
|
||||||
|
|
||||||
|
Entry fields:
|
||||||
|
|
||||||
|
- `label` — the exact rendered text users see
|
||||||
|
- `where` — semantic path (page → section/card), never spatial; see the
|
||||||
|
[writing standard](writing-ui-docs.md)
|
||||||
|
- `does` — what interacting with it produces (the outcome)
|
||||||
|
- `source` — optional; default `templ` (`templates/*.templ`); `ts` searches `web/src`
|
||||||
|
- `also` — optional, pipe-separated extra literal fragments to search when
|
||||||
|
the visible label is Alpine-composed rather than static (OR-checked with `label`)
|
||||||
|
- `note` — optional clarifier
|
||||||
|
|
||||||
|
Form-field labels inside a single modal (Name, Description…) are cited
|
||||||
|
inline in docs and not tracked here; this list carries the controls people
|
||||||
|
navigate by.
|
||||||
|
|
||||||
|
## Collections
|
||||||
|
|
||||||
|
```controls
|
||||||
|
label: New Collection
|
||||||
|
where: Collections page
|
||||||
|
does: Opens the create-collection modal.
|
||||||
|
note: the empty page offers the same action as "Create Your First Collection"
|
||||||
|
|
||||||
|
label: Create Your First Collection
|
||||||
|
where: Collections page, when no collections exist
|
||||||
|
does: Opens the create-collection modal.
|
||||||
|
|
||||||
|
label: Create Collection
|
||||||
|
where: create-collection modal → submit
|
||||||
|
does: Saves the new collection and returns to the Collections page.
|
||||||
|
|
||||||
|
label: Update Collection
|
||||||
|
where: edit-collection modal → submit
|
||||||
|
does: Saves your changes to the collection.
|
||||||
|
|
||||||
|
label: Static
|
||||||
|
where: create-collection modal → Type
|
||||||
|
does: Chooses a hand-picked collection — you add and remove items yourself.
|
||||||
|
|
||||||
|
label: ⚡ Smart
|
||||||
|
where: create-collection modal → Type; also shown as a badge on smart collections page and detail
|
||||||
|
does: Chooses a rules-matched collection — its membership stays current by itself.
|
||||||
|
|
||||||
|
label: Rules scope
|
||||||
|
where: create-collection modal, when Smart is chosen
|
||||||
|
does: Asks what the rules ask about — the two buttons below it.
|
||||||
|
also: 📚 Books|🎧 Podcast episodes
|
||||||
|
note: "Books" and "Podcast episodes" as the two scope buttons
|
||||||
|
|
||||||
|
label: Restore System
|
||||||
|
where: Collections page
|
||||||
|
does: Opens a pick-a-shelf modal to bring back a system collection you deleted.
|
||||||
|
|
||||||
|
label: Restore
|
||||||
|
where: Restore System Collection modal → submit
|
||||||
|
does: Brings the selected system collection back.
|
||||||
|
|
||||||
|
label: Edit Rules
|
||||||
|
where: collection detail page
|
||||||
|
does: Opens the smart-collection rules editor for this collection.
|
||||||
|
|
||||||
|
label: Filter tree
|
||||||
|
where: smart-collection rules editor
|
||||||
|
does: The area where the group and rules are built.
|
||||||
|
note: read/write rules — the how is the filter guide
|
||||||
|
|
||||||
|
label: Apply to Collection
|
||||||
|
where: smart-collection rules editor → save
|
||||||
|
does: Saves the filter tree and makes the collection match those rules from now on.
|
||||||
|
|
||||||
|
label: Add Items
|
||||||
|
where: collection detail page
|
||||||
|
does: Opens the item picker window.
|
||||||
|
|
||||||
|
label: Add Selected
|
||||||
|
where: item picker → footer
|
||||||
|
does: Adds everything checked in the picker to the collection.
|
||||||
|
|
||||||
|
label: Remove Selected
|
||||||
|
where: collection detail page
|
||||||
|
does: Opens a confirm dialog to remove the checked items from the collection.
|
||||||
|
|
||||||
|
label: Search within collection...
|
||||||
|
where: collection detail page → search box
|
||||||
|
does: Filters the displayed items — nothing is removed.
|
||||||
|
|
||||||
|
label: Play as Sequence
|
||||||
|
where: collection detail page (when it has a downloaded episode)
|
||||||
|
does: Plays the collection's episodes one after another in the listening order you pick.
|
||||||
|
|
||||||
|
label: Hide finished books (reading-queue mode)
|
||||||
|
where: edit-collection modal
|
||||||
|
does: Finished books disappear from the collection's shelf.
|
||||||
|
|
||||||
|
label: Show Up Next strip
|
||||||
|
where: edit-collection modal
|
||||||
|
does: Adds this collection's Up Next strip to the podcast page.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Everywhere else
|
||||||
|
|
||||||
|
```controls
|
||||||
|
label: Filters
|
||||||
|
where: All Books / Podcasts page → toolbar
|
||||||
|
does: Opens the filter drawer for building and applying rules.
|
||||||
|
|
||||||
|
label: Register Device
|
||||||
|
where: Devices page
|
||||||
|
does: Submits a device registration request for the pending-approval flow.
|
||||||
|
|
||||||
|
label: Download All
|
||||||
|
where: Podcast page → episodes area
|
||||||
|
does: Queues every episode of the feed for download.
|
||||||
|
|
||||||
|
label: Download order
|
||||||
|
where: Podcast page → feed settings
|
||||||
|
does: Sets the ordering used when feed episodes are downloaded.
|
||||||
|
|
||||||
|
label: Match ALL / Group ANY header
|
||||||
|
where: filter drawer → group header (also smart collections → Rules and dashboard sections)
|
||||||
|
does: Sets how the group combines its rules; clicking the header flips ALL/ANY.
|
||||||
|
also: ? 'Match' : 'Group'|? 'ANY' : 'ALL'
|
||||||
|
note: label is Alpine-composed — 'Match' for the whole builder, 'Group' for nested; docs write it as MATCH ALL / MATCH ANY
|
||||||
|
|
||||||
|
label: Reset to Scanned
|
||||||
|
where: book detail → edit modal → scan information area
|
||||||
|
does: Restores scanner-extracted metadata over manual edits.
|
||||||
|
|
||||||
|
label: Auto-Scan
|
||||||
|
where: Settings page → Scanning section
|
||||||
|
does: Enables automatic periodic library scanning on the interval below it.
|
||||||
|
|
||||||
|
label: Scan Interval (seconds)
|
||||||
|
where: Settings page → Scanning section
|
||||||
|
does: Sets the seconds between automatic scans.
|
||||||
|
|
||||||
|
label: Resume rewind
|
||||||
|
where: audio player → player settings; profile settings
|
||||||
|
does: Rewinds playback a configured few seconds when you resume.
|
||||||
|
also: Resume Rewind
|
||||||
|
note: profile page spells it capitalized
|
||||||
|
|
||||||
|
label: Reading Direction
|
||||||
|
where: book detail → edit modal
|
||||||
|
does: Chooses the direction pages advance (publisher convention — e.g. manga).
|
||||||
|
|
||||||
|
label: Scan Info
|
||||||
|
where: book detail page → info block
|
||||||
|
does: Displays scanner-supplied notes attached to the item.
|
||||||
|
also: Scan Information
|
||||||
|
note: the editable form label in the modal reads "Scan Information"
|
||||||
|
```
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Writing UI documentation without screenshots
|
||||||
|
|
||||||
|
Bookhoard's UI documentation uses no screenshots. This page is the standard
|
||||||
|
for writing them. The flagship example of the style is the
|
||||||
|
[filter system documentation](../user/filters/index.md): one system taught
|
||||||
|
once, the places it appears listed as a *pattern*, samples for every field in
|
||||||
|
a [generated reference](../user/filters/fields.md) that cannot drift from the
|
||||||
|
UI.
|
||||||
|
|
||||||
|
## Why no screenshots
|
||||||
|
|
||||||
|
Screenshots fail in every way the rest of this repo's rot is already gated
|
||||||
|
against:
|
||||||
|
|
||||||
|
- **They rot silently.** No gate can diff pixels against the running UI. A
|
||||||
|
renamed button makes the screenshot wrong and nothing fails.
|
||||||
|
- **They cannot be verified.** Shown text can: this app's UI is
|
||||||
|
server-rendered from `templates/*.templ`, so every stable label and heading
|
||||||
|
is a literal in the source — checkable, like routes and API examples are.
|
||||||
|
- **They die with a redesign.** Text keyed to *function* survives a
|
||||||
|
re-layout. Text keyed to a layout dies with its redesign.
|
||||||
|
- **They exclude** (screen readers, text search) and bloat diffs.
|
||||||
|
|
||||||
|
The one thing screenshots do better — "which button is it?" — text does via
|
||||||
|
**exact quoted labels** plus a semantic path, and the label gate then holds
|
||||||
|
that text against the source.
|
||||||
|
|
||||||
|
## The layering — what is load-bearing
|
||||||
|
|
||||||
|
Write every UI page in this order. The top layers are why users don't get
|
||||||
|
lost when the UI changes:
|
||||||
|
|
||||||
|
| # | Layer | Content | Lifespan |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | **The pattern** | What the thing *is*, what is invariant, every place the same pattern appears | Redesign-proof |
|
||||||
|
| 2 | **Function inventory** | What the user *can do*, phrased without control names | Redesign-proof |
|
||||||
|
| 3 | **How-to with examples** | Numbered flows: current label → the outcome it produces | Survives most changes |
|
||||||
|
| 4 | **Current labels** | Exactly quoted, bold | Gate-checked today |
|
||||||
|
| 5 | **Generated references** | Enumerables regenerated from source with a currency test | Cannot drift |
|
||||||
|
|
||||||
|
Labels are **tips, not the map**. Layer 3 may quote a label; layer 1 must
|
||||||
|
make the page followable even if that label is renamed next week.
|
||||||
|
|
||||||
|
## The page template
|
||||||
|
|
||||||
|
1. **The pattern first.** The concept, its invariants, and everywhere it
|
||||||
|
appears — "one system, three places". Never page cartography.
|
||||||
|
2. **Function inventory.** Capabilities without control names: "you can pin
|
||||||
|
any narrowing to a named filter you can reopen later".
|
||||||
|
3. **How-to with examples.** Each step: the label of what you interact with,
|
||||||
|
then the outcome ("the count at the bottom shows how many books will
|
||||||
|
match"). An example beats an adjective.
|
||||||
|
4. **Findability note.** One line telling the reader how to re-locate the
|
||||||
|
feature *by function again* after any UI change — deliberately
|
||||||
|
label-independent ("it's the drawer opened from any books or podcasts
|
||||||
|
list — the only place whose header can read Match ALL").
|
||||||
|
5. **Current labels.** Quoted exactly as rendered, one canonical spelling
|
||||||
|
per control ([arbitrated by the glossary](ui-controls.md)).
|
||||||
|
|
||||||
|
## Writing rules
|
||||||
|
|
||||||
|
- **Semantic placement, never spatial.** No "top-right" or "at the bottom of
|
||||||
|
the screen" talk. "Devices page → the **Register Device** button" survives
|
||||||
|
any re-layout.
|
||||||
|
- **One canonical label per control.** Two spellings of the same control is
|
||||||
|
a defect, not a synonym.
|
||||||
|
- **Status updates get a what-it-means table**, not a color description.
|
||||||
|
- **Screenshots are never load-bearing.** Default is none.
|
||||||
|
|
||||||
|
## Tone by audience
|
||||||
|
|
||||||
|
- **User Guide (`docs/user/`) — plain language, deliberately.** Short
|
||||||
|
sentences. Everyday words. No developer jargon; any term the reader
|
||||||
|
genuinely needs (say, *rule*) is explained once, in place, then used
|
||||||
|
consistently. The reader is assumed to know books, while the app's own
|
||||||
|
words are the only technical vocabulary allowed.
|
||||||
|
- **Developer and Administration sections — tone is a separate discussion,
|
||||||
|
not yet decided.** Write them plainly and hand-off-rich as well; do not
|
||||||
|
import opinions about their voice until that pass happens.
|
||||||
|
|
||||||
|
## Generate what has a registry
|
||||||
|
|
||||||
|
Anything enumerable gets generated from source with a currency test, not
|
||||||
|
hand-typed. The precedent: every filter field is
|
||||||
|
[generated](../user/filters/fields.md) by `TestFieldReferenceDocIsCurrent`
|
||||||
|
from the same registry the menus render — the doc cannot disagree with the
|
||||||
|
UI. Hand-typed lists drift fastest; when no registry exists yet, say so in
|
||||||
|
the page and lean on the pattern layers.
|
||||||
|
|
||||||
|
## The gates
|
||||||
|
|
||||||
|
- `TestUIControlLabels` (`internal/docs/ui_labels_test.go`): every control in
|
||||||
|
the [glossary](ui-controls.md) must exist as a literal in
|
||||||
|
`templates/*.templ` (or `web/src` for TS-rendered surfaces, marked
|
||||||
|
`source: ts`). Renames fail `make test` in the same commit as the rename.
|
||||||
|
- `TestDocsRelativeLinks`: every relative link in the docs tree resolves to
|
||||||
|
a real file.
|
||||||
|
|
||||||
|
Naming a new control in prose? Add its glossary entry in the same commit.
|
||||||
|
Form labels inside one modal (Name, Description…) are cited inline and not
|
||||||
|
glossary-tracked; the glossary carries the controls people navigate by —
|
||||||
|
buttons, tabs, badges, page titles.
|
||||||
|
|
||||||
|
## The process rule
|
||||||
|
|
||||||
|
**A UI change is not finished until the docs audit runs.** Moved card,
|
||||||
|
renamed button, restructured page — all of it triggers: labels still
|
||||||
|
resolve, links still resolve, affected pages re-read against the new
|
||||||
|
surface. The pattern layers keep users oriented between audits; the audit
|
||||||
|
keeps layers 3–5 honest.
|
||||||
|
|
||||||
|
## What this does not replace
|
||||||
|
|
||||||
|
The filter documentation (and the older filtering overview) is hands-off
|
||||||
|
exemplar territory — read it as the model, never restyle it here. New UI
|
||||||
|
surfaces (scanning, devices, settings, podcasts, the reader, collections)
|
||||||
|
are written this way from the start.
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package docs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestUIControlLabels holds the UI documentation to the source of the UI.
|
||||||
|
// The glossary (docs/developer/ui-controls.md) is the canonical spelling of
|
||||||
|
// every control the docs cite; this test greps that spelling against the
|
||||||
|
// server-rendered templates, so a renamed button fails make test in the
|
||||||
|
// same commit as the rename — the class of rot screenshots could never be
|
||||||
|
// gated. Dynamic (Alpine-composed) labels are OR-checked with their `also`
|
||||||
|
// fragments instead of requiring the composed string to exist.
|
||||||
|
//
|
||||||
|
// Scope is substring-level (not word-boundary): the point is catching
|
||||||
|
// renames and deletions, not stopping coincidental substrings. New controls
|
||||||
|
// cited in prose get a glossary entry in the same commit (writing-ui-docs.md
|
||||||
|
// is the standard; the filter docs are the hands-off exemplar).
|
||||||
|
|
||||||
|
const (
|
||||||
|
labelGlossaryRel = "../../docs/developer/ui-controls.md"
|
||||||
|
labelTemplRootRel = "../../templates"
|
||||||
|
labelTSRootRel = "../../web/src"
|
||||||
|
)
|
||||||
|
|
||||||
|
type docGlossaryEntry struct {
|
||||||
|
label string
|
||||||
|
where string
|
||||||
|
does string
|
||||||
|
source string
|
||||||
|
reason string
|
||||||
|
also []string
|
||||||
|
skip bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// docCorpus concatenates every file under root ending in suffix.
|
||||||
|
func docCorpus(t *testing.T, root string, suffix string) string {
|
||||||
|
t.Helper()
|
||||||
|
abs, err := filepath.Abs(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("abs %s: %v", root, err)
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
err = filepath.WalkDir(abs, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() || !strings.HasSuffix(path, suffix) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.Write(raw)
|
||||||
|
b.WriteString("\n")
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("walk %s: %v", root, err)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func corpusName(source string) string {
|
||||||
|
if source == "ts" {
|
||||||
|
return "web/src"
|
||||||
|
}
|
||||||
|
return "templ"
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUIControlsBlock(t *testing.T, raw string) []docGlossaryEntry {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var block strings.Builder
|
||||||
|
inBlock, foundAny := false, false
|
||||||
|
for _, line := range strings.Split(raw, "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(trimmed, "```controls"):
|
||||||
|
inBlock, foundAny = true, true
|
||||||
|
continue
|
||||||
|
case trimmed == "```" && inBlock:
|
||||||
|
inBlock = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inBlock {
|
||||||
|
block.WriteString(line)
|
||||||
|
block.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundAny {
|
||||||
|
t.Fatalf("no ```controls block found in %s", labelGlossaryRel)
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries []docGlossaryEntry
|
||||||
|
haveCur := false
|
||||||
|
cur := docGlossaryEntry{}
|
||||||
|
flush := func() {
|
||||||
|
if haveCur {
|
||||||
|
entries = append(entries, cur)
|
||||||
|
haveCur = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(block.String(), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, val, ok := strings.Cut(line, ":")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("malformed controls line %q (want `key: value`)", line)
|
||||||
|
}
|
||||||
|
key, val = strings.TrimSpace(key), strings.TrimSpace(val)
|
||||||
|
switch key {
|
||||||
|
case "label":
|
||||||
|
flush()
|
||||||
|
haveCur = true
|
||||||
|
cur = docGlossaryEntry{label: val}
|
||||||
|
case "where":
|
||||||
|
cur.where = val
|
||||||
|
case "does":
|
||||||
|
cur.does = val
|
||||||
|
case "source":
|
||||||
|
cur.source = val
|
||||||
|
case "also":
|
||||||
|
for _, frag := range strings.Split(val, "|") {
|
||||||
|
if frag = strings.TrimSpace(frag); frag != "" {
|
||||||
|
cur.also = append(cur.also, frag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "skip":
|
||||||
|
cur.skip = val == "true"
|
||||||
|
case "reason":
|
||||||
|
cur.reason = val
|
||||||
|
case "note":
|
||||||
|
// free text for humans
|
||||||
|
default:
|
||||||
|
t.Fatalf("unknown field in controls block: %q (allowed: label|where|does|source|also|skip|reason|note)", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIControlLabels(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile(labelGlossaryRel)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read glossary: %v", err)
|
||||||
|
}
|
||||||
|
entries := parseUIControlsBlock(t, string(raw))
|
||||||
|
if len(entries) == 0 {
|
||||||
|
t.Fatalf("glossary has no entries — add at least one before citing controls")
|
||||||
|
}
|
||||||
|
|
||||||
|
tmplRequired, tsRequired := false, false
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.source == "ts" {
|
||||||
|
tsRequired = true
|
||||||
|
} else {
|
||||||
|
tmplRequired = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
corpora := map[string]string{}
|
||||||
|
if tmplRequired {
|
||||||
|
corpora[corpusName("")] = docCorpus(t, labelTemplRootRel, ".templ")
|
||||||
|
}
|
||||||
|
if tsRequired {
|
||||||
|
corpora[corpusName("ts")] = docCorpus(t, labelTSRootRel, ".ts")
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[string]int{}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.label == "" {
|
||||||
|
t.Errorf("entry with empty label (where: %q, does: %q)", e.where, e.does)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[e.label]++
|
||||||
|
if e.where == "" || e.does == "" {
|
||||||
|
t.Errorf("control %q: `where` and `does` are required", e.label)
|
||||||
|
}
|
||||||
|
if e.skip && e.reason == "" {
|
||||||
|
t.Errorf("control %q: skip=true requires a reason", e.label)
|
||||||
|
}
|
||||||
|
switch e.source {
|
||||||
|
case "", "templ", "ts":
|
||||||
|
default:
|
||||||
|
t.Errorf("control %q: unknown source %q (templ|ts)", e.label, e.source)
|
||||||
|
}
|
||||||
|
if e.skip {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fragments := append([]string{e.label}, e.also...)
|
||||||
|
found := false
|
||||||
|
for _, frag := range fragments {
|
||||||
|
if strings.Contains(corpora[corpusName(e.source)], frag) {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("control %q (%s): none of the fragments [%s] found in %s sources — the label was renamed or the glossary is stale. Update ui-controls.md in the same commit as the rename.",
|
||||||
|
e.label, e.where, strings.Join(fragments, " | "), corpusName(e.source))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for label, n := range seen {
|
||||||
|
if n > 1 {
|
||||||
|
t.Errorf("control %q listed %d times — one entry per control", label, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user