diff --git a/app/(app)/magazines/[id]/page.tsx b/app/(app)/magazines/[id]/page.tsx index 45da0588..773e8b98 100644 --- a/app/(app)/magazines/[id]/page.tsx +++ b/app/(app)/magazines/[id]/page.tsx @@ -25,12 +25,14 @@ export default async function MagazineDetailPage({ params }: PageProps) { // getMagazine throws NotFoundError for a record not owned or shared — the // not-found path never reveals existence (R9). It returns the permission so we // don't re-resolve it. - const { magazine: row, permission } = await getMagazine(user.id, id).catch( - (error: unknown) => { - if (error instanceof NotFoundError) notFound(); - throw error; - }, - ); + const { + magazine: row, + permission, + ownerMagpulMode, + } = await getMagazine(user.id, id).catch((error: unknown) => { + if (error instanceof NotFoundError) notFound(); + throw error; + }); const [firearms, caliberSuggestions, prefixData] = await Promise.all([ listFirearms(user.id), @@ -76,7 +78,7 @@ export default async function MagazineDetailPage({ params }: PageProps) { caliberSuggestions={caliberSuggestions} prefixOptions={prefixData.prefixes} prefixNextStart={prefixData.nextStart} - magpulMode={user.magpulMode} + ownerMagpulMode={ownerMagpulMode} /> ); } diff --git a/app/(app)/magazines/dot-matrix-label.tsx b/app/(app)/magazines/dot-matrix-label.tsx new file mode 100644 index 00000000..9d5573af --- /dev/null +++ b/app/(app)/magazines/dot-matrix-label.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useMemo } from "react"; +import { Callout } from "@/components/ui/feedback"; +import { + type DotMatrixResult, + resolveDotMatrix, +} from "@/src/domain/magazines/dot-matrix"; +import { MAGPUL_GLYPHS } from "@/src/domain/magazines/glyphs"; + +/** + * Renders a magazine's label as Magpul PMAG Gen M3 paint-pen dot-matrix + * glyphs (U5/U6; R4, R9, R11-R15; KTD1, KTD5, KTD6, KTD8, KTD9). Resolves + * `resolveDotMatrix` itself and branches once on the result, so the caller + * (`magazine-detail-view.tsx`) only ever places this component — it never + * needs to know which of the three outcomes applies. Ships against + * `MAGPUL_GLYPHS`, which is empty until the glyph table is transcribed + * (KTD3), so `hidden` is the shipped outcome today. + */ + +// --- Geometry (KTD6): fixed, no responsive scaling. Named constants, not +// literals scattered through the JSX. A 4-cell matrix comes out to +// 216 x 74 px, sized to fit the detail card's real ~248px content width at a +// 320px viewport. +const DOT_PITCH_PX = 16; +export const PAINTED_DOT_DIAMETER_PX = 10; +export const UNPAINTED_DOT_DIAMETER_PX = 6; +const CELL_GAP_PX = 16; +const GLYPH_COLUMNS = 3; +const GLYPH_ROWS = 5; +// The larger (painted) diameter sets the padding and cell footprint so a +// painted dot's edge never clips the cell boundary, whichever state a given +// dot is actually in. +const CELL_WIDTH_PX = + (GLYPH_COLUMNS - 1) * DOT_PITCH_PX + PAINTED_DOT_DIAMETER_PX; +const CELL_HEIGHT_PX = + (GLYPH_ROWS - 1) * DOT_PITCH_PX + PAINTED_DOT_DIAMETER_PX; +const DOT_CENTER_OFFSET_PX = PAINTED_DOT_DIAMETER_PX / 2; + +// --- Copy (KTD8): defined once here and used by both the SVG's accessible +// name and the R4/R9 messages rendered alongside it. +export const MODEL_NOT_RECOGNIZED_CAVEAT = + "Model not recognized — confirm this floorplate has 4 dot cells before painting."; +export const LABEL_DOES_NOT_FIT_MESSAGE = + "This label does not fit this magazine's floorplate."; +export const UNVERIFIED_CELL_COUNT_SUFFIX = + " The model was not recognized, so the 4-cell count is unverified."; + +/** KTD8: names the cell count, then the drawn characters — phrased so it + * cannot be mistaken for a duplicate of the stored label or a truncation + * error, since the two differ whenever R8 drops a prefix. */ +function buildAriaLabel( + cellCount: number, + characters: readonly string[], +): string { + return `Dot pattern to paint on a ${cellCount}-cell floorplate: ${characters.join(" ")}`; +} + +/** R9, extended with the unverified clause when the cell count came from + * R4's unrecognized-model fallback rather than a matched entry. */ +function buildDoesNotFitMessage(cellCountVerified: boolean): string { + return cellCountVerified + ? LABEL_DOES_NOT_FIT_MESSAGE + : `${LABEL_DOES_NOT_FIT_MESSAGE}${UNVERIFIED_CELL_COUNT_SUFFIX}`; +} + +interface DotMatrixLabelProps { + label: string; + brandModel: string; + /** The magazine owner's Magpul mode (R6) — not necessarily the viewer's. */ + ownerMagpulMode: boolean; +} + +export function DotMatrixLabel({ + label, + brandModel, + ownerMagpulMode, +}: DotMatrixLabelProps) { + // KTD9: keyed on primitives, never a parent-owned object — this repo runs + // reactCompiler: true, where a freshly-built array handed to a memoized + // child renders stale. MAGPUL_GLYPHS is a module-level constant, not a + // dependency. + const result: DotMatrixResult = useMemo( + () => + resolveDotMatrix({ + label, + brandModel, + ownerMagpulMode, + glyphs: MAGPUL_GLYPHS, + }), + [label, brandModel, ownerMagpulMode], + ); + + // R10/KTD3: hidden renders nothing at all — no SVG, no empty container, no + // placeholder grid, no caption. Returning null here (rather than an empty + // wrapper in the caller) is what keeps that guarantee exact. + if (result.kind === "hidden") return null; + + if (result.kind === "unrepresentable") { + return ( + + {buildDoesNotFitMessage(result.cellCountVerified)} + + ); + } + + const { characters, cells, cellCount, cellCountVerified } = result; + // Sized from `cells.length` (how many cell groups are actually drawn by the + // .map below), NOT `cellCount` (the floorplate's total capacity, used only + // for `buildAriaLabel`). A label shorter than the floorplate's cell count + // — the ordinary case — draws fewer groups than the floorplate holds, and + // sizing the canvas off the untouched `cellCount` would leave blank, + // unexplained space to the right of the drawn dots. + const width = cells.length * CELL_WIDTH_PX + (cells.length - 1) * CELL_GAP_PX; + + return ( +
+ + {cells.map((cell, cellIndex) => { + const cellOffsetX = cellIndex * (CELL_WIDTH_PX + CELL_GAP_PX); + return cell.map((row, rowIndex) => + row.map((painted, columnIndex) => { + const cx = + cellOffsetX + DOT_CENTER_OFFSET_PX + columnIndex * DOT_PITCH_PX; + const cy = DOT_CENTER_OFFSET_PX + rowIndex * DOT_PITCH_PX; + const diameter = painted + ? PAINTED_DOT_DIAMETER_PX + : UNPAINTED_DOT_DIAMETER_PX; + return ( + + ); + }), + ); + })} + + {!cellCountVerified ? ( + {MODEL_NOT_RECOGNIZED_CAVEAT} + ) : null} +
+ ); +} diff --git a/app/(app)/magazines/magazine-detail-view.tsx b/app/(app)/magazines/magazine-detail-view.tsx index e4d6a1f7..f38c3cd7 100644 --- a/app/(app)/magazines/magazine-detail-view.tsx +++ b/app/(app)/magazines/magazine-detail-view.tsx @@ -14,6 +14,7 @@ import { useDeleteConfirmation } from "@/hooks/use-delete-confirmation"; import type { Permission } from "@/src/auth/visibility"; import { InventoryLogHistory } from "../inventory-log/inventory-log-history"; import { deleteMagazineAction } from "./actions"; +import { DotMatrixLabel } from "./dot-matrix-label"; import { type FirearmOption, MagazineForm, @@ -34,7 +35,7 @@ interface MagazineDetailViewProps { caliberSuggestions: string[]; prefixOptions: string[]; prefixNextStart: Record; - magpulMode: boolean; + ownerMagpulMode: boolean; } export function MagazineDetailView({ @@ -44,7 +45,7 @@ export function MagazineDetailView({ caliberSuggestions, prefixOptions, prefixNextStart, - magpulMode, + ownerMagpulMode, }: MagazineDetailViewProps) { const router = useRouter(); const [editing, setEditing] = useState(false); @@ -131,7 +132,7 @@ export function MagazineDetailView({ caliberSuggestions={caliberSuggestions} prefixOptions={prefixOptions} prefixNextStart={prefixNextStart} - magpulMode={magpulMode} + magpulMode={ownerMagpulMode} onDone={() => { setEditing(false); router.refresh(); @@ -194,6 +195,20 @@ export function MagazineDetailView({ } /> + {/* Below the field list, closest to the Label row it paints + (R13/R14). Not spliced mid-
: that would require breaking + the list into two
s, which shifts the `last:border-b-0` + boundary onto the Label row's own DetailRow and changes its + rendered border even when Magpul mode is off — the DetailRow + itself must stay exactly as it is (U6 verification: unchanged + render when mode is off). Renders neither a caption nor a + placeholder when hidden (R10/KTD3), so nothing appears here in + the shipped-dark state. */} + )} diff --git a/app/globals.css b/app/globals.css index 490cee58..cf9030b0 100644 --- a/app/globals.css +++ b/app/globals.css @@ -53,6 +53,15 @@ --danger-soft: #2c1816; --ok: #57d98a; + /* Dot-matrix paint-pen glyph tokens (KTD5) — aliases, not new colors, so a + future retune of --foreground/--muted-foreground tracks automatically + and the R15 contrast guard has a stable name to assert against. + --border reads as this theme's "faint but present" token but fails + WCAG 1.4.11's 3:1 floor against --card (1.26:1); --muted-foreground + clears it (5.32:1). */ + --dot-painted: var(--foreground); + --dot-unpainted: var(--muted-foreground); + --radius: 0.375rem; --radius-lg: 0.625rem; --shadow-raised: 0 1px 2px rgb(0 0 0 / 0.35), 0 2px 10px rgb(0 0 0 / 0.3); @@ -84,6 +93,12 @@ --border: #dcdad3; --input: #cbc8bf; + /* Dot-matrix paint-pen glyph tokens (KTD5) — aliases, see the dark theme + block above. --border fails WCAG 1.4.11's 3:1 floor against --card + (1.40:1); --muted-foreground clears it (5.15:1). */ + --dot-painted: var(--foreground); + --dot-unpainted: var(--muted-foreground); + /* Deepened a touch from #bd4620 so accent text on --accent (active nav, badges) clears AA 4.5:1; still the anodized burnt-orange. */ --primary: #b6431f; @@ -132,6 +147,8 @@ --color-steel: var(--steel); --color-danger-soft: var(--danger-soft); --color-ok: var(--ok); + --color-dot-painted: var(--dot-painted); + --color-dot-unpainted: var(--dot-unpainted); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); --radius: var(--radius); diff --git a/docs/plans/2026-08-02-001-feat-magazine-dot-matrix-label-plan.md b/docs/plans/2026-08-02-001-feat-magazine-dot-matrix-label-plan.md new file mode 100644 index 00000000..0ee17c2d --- /dev/null +++ b/docs/plans/2026-08-02-001-feat-magazine-dot-matrix-label-plan.md @@ -0,0 +1,526 @@ +--- +title: Magazine Dot-Matrix Label Rendering - Plan +type: feat +date: 2026-08-02 +topic: magazine-dot-matrix-label +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +product_contract_source: ce-brainstorm +execution: code +--- + +# Magazine Dot-Matrix Label Rendering - Plan + +## Goal Capsule + +- **Objective:** Render a magazine's `label` in the detail view as Magpul PMAG dot-matrix glyphs, so an owner can copy the mark onto a floorplate with a paint pen and recognize a painted magazine from its record. Resolves GitHub issue #20. +- **Product authority:** Owner (`unclesp1d3r`), via brainstorm. Decisions below are pinned unless planning surfaces a conflict. +- **Font transcribed; feature live.** `src/data/magpul-glyphs.txt` now carries all 36 glyphs (`0`-`9`, `A`-`Z`) transcribed from Magpul's diagram, so KTD3's suppression no longer applies and the matrix renders for any magazine whose owner has Magpul mode on. The model-to-cell-count list still covers only the two seed families R5 names; a `brandModel` outside them renders under R4's unverified 4-cell fallback until more counts are sourced. +- **Stop conditions:** Stop and surface a blocker if the owner's `magpulMode` cannot be resolved server-side without a second round trip, or if `--muted-foreground` fails the 3:1 contrast assertion in U5 (that would mean a token was retuned since planning and R15 needs a new token, which is a product-visible choice). +- **Tail ownership:** The caller owns branch, commit, and PR. Do not open a PR from inside implementation. + +--- + +## Product Contract + +### Summary + +The magazine detail view renders `label` as Magpul PMAG Gen M3 dot-matrix glyphs — one 3x5 dot cell per character, painted dots solid against faint unpainted positions. How many cells are drawn is derived from the magazine's `brandModel`. When a label is longer than that magazine's floorplate holds, only its trailing digits render. + +### Problem Frame + +A PMAG Gen M3 floorplate carries a molded dot matrix, and Magpul publishes a diagram mapping each character to a dot pattern. Owners paint those dots to mark a short identifier on each magazine. `magazine.label` is that identifier, and the #22 prefix feature already shapes it as `PREFIX + number` (e.g. `US04`) precisely so it can be painted. + +Today both the detail view and the list row show `label` as plain monospace text. An owner holding a paint pen has to translate characters into dot patterns from a separate PDF, and an owner holding an already-painted magazine has to translate the other way to find its record. + +Floorplate capacity is not uniform. A 7.62x51 PMAG carries 4 dot cells; a GL9 carries 2. Nothing in the schema distinguishes them — `brandModel` and `caliber` are both free text, and unlike `firearm`, the `magazine` table has no type taxonomy or CHECK constraint on model. So the number of cells a given magazine can hold is not currently derivable from stored data. + +### Key Decisions + +- **Render-only; input rules are untouched.** The matrix truncates what it draws; the stored label is never altered, shortened, or re-validated. The shipped #21 validator, the form input mask, and #22's auto-numbering all stay exactly as they are. Governs R6, R7, R8, R9. +- **Cell count is derived from `brandModel`.** Chosen over ignoring floorplate variation and over storing an explicit per-magazine cell count, both of which were weighed. Governs R3, R4. +- **An unrecognized model falls back to 4 cells, shown as unverified.** Rendering nothing would disable the feature for every model absent from a deliberately small list, but a silent 4-cell mark is confidently wrong guidance for a permanent painted mark on a smaller floorplate. Marking the fallback unverified keeps the feature useful and puts the uncertainty where the owner acts on it. This is why R5's list carries known 4-cell models rather than only the models that differ: without them a genuinely 4-cell magazine could never be a match, so the caveat would fire on the common case and stop carrying any signal. Governs R4. +- **An over-long label renders its number component only.** The prefix is what an owner drops when the floorplate is short; the number is what distinguishes one magazine from the next. Because numbering runs per prefix, this makes a short-floorplate mark unique within a prefix but not across the whole inventory — `US04` and `EU04` both paint `04`. Accepted as a limitation of the hardware rather than designed around. Governs R8. +- **A label that cannot be represented renders nothing and says so.** Chosen over truncating to leading or trailing characters, either of which would paint a mark that is not the magazine's label. Governs R9. +- **Unpainted dot positions stay visible.** Weighed against drawing painted dots alone and against outlining each character cell. Showing the whole cell is what helps while aiming a paint pen. Governs R11. +- **Rendering the glyphs, rather than linking Magpul's diagram beside the label.** A link is far cheaper and carries none of the matching or transcription risk, but leaves the owner doing the character-to-dot translation by hand in both directions — which is the friction this feature exists to remove. This release removes that friction in the painting direction only. Recognizing an unidentified painted magazine still means opening records one at a time, because the list-row glyph that would let an owner scan for a matching pattern is deferred. Governs R1, R2, R14. +- **A floorplate's cell count means one face.** A magazine that carries a matrix on both faces is marked with the same characters twice, not with one mark split across the two sides — a mark you have to turn the magazine over to finish reading defeats the at-a-glance recognition this feature is for. This matters only where a single face holds fewer than 4 cells, since #21 caps labels at 4 characters and no floorplate of 4 or more is ever the binding constraint. Governs R3, R5. (session-settled: user-directed) +- **The model-to-cell-count list is built in and not owner-editable.** Keeps the surface small; an owner whose model is missing gets the 4-cell fallback rather than a management UI. Governs R5. (session-settled: user-directed) + +### Requirements + +**Glyph font** + +- R1. The glyph font covers `0`-`9` and `A`-`Z`, one glyph per character, transcribed from Magpul's published PMAG Gen M3 dot-matrix diagram. Magpul's sheet carries no hyphen and no punctuation. Consequence: #21 still permits a hyphen in a stored label, so a label containing one is unrepresentable under R9. Open product question, not decided here — drop hyphen from the allowed character set, or accept that behavior. +- R2. Each glyph occupies a fixed dot cell of 3 columns by 5 rows. A hyphen occupies one cell like any other glyph. + +**Cell count** + +- R3. The number of dot cells drawn for a magazine is derived from its `brandModel`. +- R4. A `brandModel` matching no known entry falls back to 4 cells, and the view states that the model was not recognized and the owner should confirm their floorplate holds 4 cells before painting. +- R5. The model-to-cell-count mapping is a built-in list; owners cannot add to or edit it. A model earns an entry whatever its count, including models that hold 4, so that a match means a confirmed cell count rather than one defaulted by R4. The list starts with the GL9 family at 2 cells and the 7.62x51 PMAG family at 4. + +**What renders** + +- R6. The matrix renders only when the magazine owner's Magpul mode is on, per R10 of `docs/plans/2026-07-01-001-feat-magpul-mode-label-constraint-plan.md`. +- R7. When the label's length is within the magazine's cell count, every character renders as its glyph. +- R8. When the label exceeds the cell count, only its trailing run of digits renders. +- R9. When the label cannot be represented, no matrix renders and the view states that the label does not fit this magazine's floorplate. A label cannot be represented when it contains a character absent from the glyph font, or when it exceeds the cell count and its trailing digit run is absent or itself exceeds the cell count. When the cell count came from R4's unrecognized-model fallback rather than a matched entry, the message also states that the cell count is unverified, so the owner can tell a true overflow from one inferred off a guessed capacity. +- R10. An empty label renders no matrix and no placeholder grid. + +```mermaid +flowchart TB + A[Magazine detail view] --> B{Owner's Magpul mode on?} + B -->|no| Z[No matrix] + B -->|yes| C{Label empty?} + C -->|yes| Z + C -->|no| D[Cell count from brandModel; unmatched falls back to 4] + D --> E{Every character in the glyph font?} + E -->|no| Y[No matrix; state that the label does not fit] + E -->|yes| F{Label within cell count?} + F -->|yes| G[Render every character] + F -->|no| H{Trailing digit run fits?} + H -->|yes| I[Render trailing digits only] + H -->|no| Y +``` + +**Presentation and accessibility** + +- R11. Painted dots render solid; unpainted dot positions within each cell render faint but visible. +- R12. The matrix is theme-aware in both light and dark themes, using the app's existing tokens. +- R13. The matrix carries an accessible text alternative naming the characters actually drawn, and the `label` remains present in the detail view as readable text. The alternative is phrased so it reads as the pattern to paint rather than as a second copy of the label or a truncation error, since the two differ whenever R8 drops a prefix. Targeted via ARIA roles and accessible names, no `data-testid`. +- R14. The matrix appears in the magazine detail view only. +- R15. Painted dots and unpainted dot positions each meet a 3:1 non-text contrast ratio (WCAG 1.4.11) against the surface behind them, in both themes, and remain visually distinct from one another. + +### Acceptance Examples + +- AE1. **Covers R3, R7.** Magpul mode on; a 4-cell PMAG labeled `US04` renders four glyphs: `U`, `S`, `0`, `4`. +- AE2. **Covers R3, R8.** Magpul mode on; a 2-cell GL9 labeled `US04` renders two glyphs: `0`, `4`. The prefix is not drawn. +- AE3. **Covers R4, R7.** Magpul mode on; a magazine whose `brandModel` matches no entry, labeled `AR12`, renders four glyphs alongside a statement that the model was not recognized and the floorplate should be confirmed before painting. +- AE4. **Covers R9.** Magpul mode on; a 2-cell GL9 whose stored label is `AR-X` renders no matrix and states that the label does not fit this magazine's floorplate. +- AE5. **Covers R9.** Magpul mode on; a 4-cell PMAG whose stored label is `A.1` (a pre-existing label containing an unsupported character) renders no matrix and states that the label does not fit. +- AE6. **Covers R6.** Magpul mode off; no matrix renders, whatever the label contains. +- AE7. **Covers R10.** Magpul mode on; a magazine with an empty label renders no matrix. +- AE8. **Covers R8, R13.** A 2-cell GL9 labeled `US04` exposes an accessible alternative naming `04` as the pattern to paint, distinguishable from the stored label rather than reading as a bare duplicate of it, and the detail view still shows `US04` as text. +- AE9. **Covers R9.** Magpul mode on; a 2-cell GL9 labeled `1234` renders no matrix and states that the label does not fit this magazine's floorplate. The trailing digit run is the whole label and still exceeds the cell count. + +### Scope Boundaries + +#### Deferred for later + +- The inline dot-matrix glyph on the magazine list row — readability at that size is judged after the detail view ships. +- A print or export of the pattern as a paint-pen guide. +- An owner-editable model-to-cell-count list, or any UI for correcting an unrecognized model. + +#### Out of scope + +- Any change to label input rules: character set, maximum length, validation, or the form input mask. Those are owned by #21 and stay as shipped. +- Any change to #22's prefix auto-numbering. Because cell count never constrains input, numbering is unaffected. +- Any schema change. This is presentation over the existing `label`. +- Floorplate variants that use a glyph font other than the PMAG Gen M3 matrix. Variants differing only in cell count are covered by R3. + +#### Deferred to follow-up work + +- **Extending the model-to-cell-count list** in `src/domain/magazines/floorplate.ts` beyond the two seed families. Data-only. + +The glyph table transcription and the rendered-matrix E2E assertions this section previously deferred are both done — see Dependencies and U7. + +### Dependencies / Assumptions + +- **Transcribed glyph table (done).** The dot patterns for R1 were transcribed by the owner from Magpul's diagram (`https://magpul.com/media/wysiwyg/Instructions/Magpul_Dot_Matrix.pdf`) into `src/data/magpul-glyphs.txt`, cross-checked against the community matrix reference listed in Sources, with the Magpul PDF winning ties. All 36 glyphs (`0`-`9`, `A`-`Z`) are present; no hyphen, per R1. No longer blocking — KTD3's suppression is now a no-op. +- **Blocking prerequisite: cell counts, sourced separately.** The Magpul PDF carries only the character font, not per-model floorplate capacity, so R5's list cannot be derived from it. Counts come from counting dot cells on physical floorplates or from a per-model source, and are needed for common 4-cell models as much as for the ones that differ — an unlisted 4-cell model still renders under R4's fallback caveat. An entry taken from a published per-model source is cross-checked against a second independent source before it is treated as authoritative, mirroring the glyph-table cross-check above; a count taken by counting dot cells on the physical floorplate is authoritative on its own. A wrong count on a matched model is worse than an unrecognized one, because R4's unverified caveat attaches only to models that miss the list — a miscounted entry renders as though it were confirmed. +- **Depends on #21 (shipped) and needs new read-path wiring.** Reuses the per-account `magpulMode` flag and the shared constants in `src/domain/magazines/constants.ts`. R6 keys on the magazine **owner's** flag, but `app/(app)/magazines/[id]/page.tsx` currently passes the *viewer's* session flag under the same `magpulMode` name; resolving the owner's flag is a new server-side lookup, not reuse of the existing prop. +- **Grid dimensions confirmed by the sheet.** R2's 3x5 cell is no longer an assumption carried over from #21 — Magpul's diagram confirms a 3-column x 5-row cell for every transcribed glyph, matching U1's row-width format, the `GlyphCell` type, and KTD6's geometry as shipped. +- **A trailing digit run that still overflows is treated as unrepresentable** (R9) rather than truncated further. This is reachable by ordinary input, not only by pre-existing labels: #21's length cap is a flat 4 characters and is not scoped per floorplate, so a freshly entered all-digit label such as `1234` on a 2-cell GL9 has a trailing digit run longer than the magazine's cell count. +- **Matching is against a normalized `brandModel`.** Exact normalization and matching are planning decisions — see KTD4. A miss is not harmless — it produces a 4-cell render for a magazine that may hold fewer — which is why R4 marks the fallback unverified rather than presenting it as known. + +### Sources / Research + +- GitHub issue #20, and the owner's comment recording that cell count varies by magazine type. +- Magpul Dot Matrix Diagram (authoritative glyph source): `https://magpul.com/media/wysiwyg/Instructions/Magpul_Dot_Matrix.pdf` +- Community PMAG M3 matrix reference: `https://www.ar15.com/media/viewFile.html?i=36785` +- `src/domain/magazines/constants.ts` — `MAX_LABEL_LENGTH` (4), `MAGPUL_LABEL_ALLOWED_RE`, `MAGPUL_LABEL_DISALLOWED_CHAR_RE`, `normalizeMagpulLabel`. Its doc comment already names #20 as a future consumer. +- `src/db/auth-schema.ts` — the `magpulMode` column, surfaced through Better Auth `additionalFields`. +- `src/db/inventory-schema.ts` — the `magazine` table; `brandModel` and `caliber` are free text with no model taxonomy or CHECK. +- `app/(app)/magazines/magazine-detail-view.tsx` — renders `label` as monospace text today. +- `app/(app)/magazines/magazines-view.tsx` — renders `label` as monospace text in the list row. +- `docs/plans/2026-07-01-001-feat-magpul-mode-label-constraint-plan.md` — R10 (no rendering when mode is off), R11 (stored nonconforming labels preserved verbatim). +- `docs/plans/2026-07-03-002-feat-magazine-label-prefix-numbering-plan.md` — the `PREFIX + number` label shape this feature paints. +- `docs/adr/0007-magpul-constraint-domain-layer-not-db.md`, `docs/adr/0008-magpul-mode-better-auth-additional-field.md`. +- W3C, *Understanding SC 1.4.11 Non-text Contrast* — the "required to understand what the graphic is conveying" test, and the radio-button precedent that object *states* need contrast against their own background, not against each other. Shapes KTD5. +- `docs/solutions/runtime-errors/tanstack-autoreset-render-loop-unstable-data.md` — freshly-built arrays passed to memoized children break under this repo's `reactCompiler: true`. Shapes KTD9. +- `docs/solutions/best-practices/prefix-collision-safe-token-renaming.md` — Tailwind v4 silently no-ops unknown utility classes, so a wrong token name produces no error. Shapes the U5 contrast test. +- `docs/solutions/test-failures/bun-test-misloads-playwright-e2e-specs.md` — never run bare `bun test`. + +--- + +## Planning Contract + +**Product Contract preservation:** unchanged. No R was added, split, reworded, or renumbered; the two settled Key Decisions gained a `(session-settled: user-directed)` provenance annotation and nothing else. One planning-level gate (KTD3) narrows *when* the matrix renders while the glyph table is empty; it is a no-op once the table has rows and alters no R. + +### Key Technical Decisions + +- KTD1. **Render as inline SVG, one `` per dot.** Chosen over a CSS grid of rounded `div`s and over canvas. SVG gives a single accessible-name attachment point — `role="img"` prunes the SVG's children from the accessibility tree, so ~60 dots need no per-dot `aria-hidden` — plus proportional scaling from one `viewBox` and `fill` that reads theme custom properties through the normal cascade. Canvas is invisible to assistive tech without hand-built fallback DOM and needs manual `devicePixelRatio` redraw. The cost is more markup per dot; at 4 cells x 15 dots that is irrelevant. Governs R11, R13, R15. +- KTD2. **The glyph table and the cell-count list are arguments to the resolver, not imports inside it.** `resolveDotMatrix` takes the glyph table as a parameter; production wiring supplies the parsed file, tests supply a synthetic table. This is what makes the two blocking prerequisites block only the visible result: every rule in R7-R10 is fully testable today against a three-glyph fixture. Governs R1, R3, R5, R7, R8, R9. +- KTD3. **An empty glyph table suppresses the matrix entirely, exactly as Magpul mode off does.** Not an R9 "does not fit" — that message is about a label whose characters are missing from a *real* font, and firing it for every label while the table is untranscribed would tell owners their labels are wrong when the app simply has no font yet. This is the mechanism by which the feature ships dark. Governs R6, R9. +- KTD4. **`brandModel` matches by required-substring containment against an ordered list; first match wins.** Normalize by uppercasing and removing every character outside `A-Z0-9`, yielding one dense token (`"Magpul PMAG 17 GL9"` -> `MAGPULPMAG17GL9`). Each list entry carries the substrings a model must contain, keyed on a *distinctive Magpul model designation alone* (`["GL9"]` -> 2 cells) — never a brand token, and never a caliber. Requiring `PMAG` would reject the natural shorthand `Magpul GL9`, because `MAGPUL` does not contain the substring `PMAG` — and a miss there is not harmless, since it drops an unambiguous 2-cell magazine to the unverified 4-cell fallback. A caliber token is banned for the opposite failure direction: caliber is brand-agnostic, so a bare `762X51` token matches any manufacturer's magazine carrying that caliber, not just Magpul's — a code review caught `"PTR-91 7.62X51"` (a PTR magazine, no PMAG floorplate) resolving as a *confirmed* 4-cell match under an earlier draft that included such a token. Both directions matter: a brand token under-matches Magpul's own shorthand naming, and a caliber token over-matches every other brand that happens to share the round. Exact matching cannot work on free text, and a regex-per-entry list is harder to audit than a token set. Order the list most-specific-first so a narrow family shadows a broad one. The counts stored in the list are per-face, per the Product Contract's one-face Key Decision (Governs R3, R5). Governs R3, R4, R5. +- KTD5. **Painted dots use `--foreground`; unpainted dots use `--muted-foreground`.** Measured against `--card` in `app/globals.css`: `--border` fails R15's 3:1 floor in both themes (1.26:1 dark, 1.40:1 light) despite being the repo's "faint but present" token, while `--muted-foreground` clears it (5.32:1 dark, 5.15:1 light). The anodized-orange accent was considered — `CONCEPTS.md` gives it the "active / lit / marked" semantic — and rejected because the accent marks interactive state, and a painted dot is static content the owner copies in black ink. **R15's "visually distinct from one another" clause rests on radius, not on colour contrast between the two tokens.** Painted-against-unpainted measures 2.67:1 in the dark theme and 3.30:1 in light — enough to tell apart, but too little headroom in dark to make a colour threshold the guarantee. KTD6's smaller unpainted dot carries the distinction instead, and it survives any future retune of either token. Governs R11, R12, R15. +- KTD6. **Fixed dot geometry, no responsive scaling: 16px pitch, 10px painted diameter, 6px unpainted diameter, 16px gap between cells.** A 4-cell matrix is then 216 x 74 px. The budget it has to fit is 248px: a 320px viewport, less the app shell's horizontal padding, less the 20px-per-side `p-5` on the `Card` in `components/ui/surface.tsx`. The 32px of slack absorbs shell-padding variation. Published guidance for "minimum size to hand-transcribe a dot pattern" does not exist; the floor is derived from dot-peen marking practice (MIL-STD-130 sets ~2mm minimum human-readable character height for exactly this read-and-reproduce task) and from dot-matrix display convention, where dot diameter stays near half the pitch so adjacent lit dots read as discrete marks rather than merging. That yields a floor of roughly 6px diameter and 12-14px pitch; the painted dot sits above it and the unpainted dot sits at it, which is right for a positioning aid rather than the mark being traced. Fluid `vw` scaling is rejected because a component inside a narrow card can breach the floor even on a wide viewport. Governs R11, R15; resolves the deferred minimum-dot-size question. +- KTD7. **The owner's `magpulMode` is resolved by extending `getMagazine`'s return, not by a second call from the page.** The page cannot join a separate owner lookup into its existing `Promise.all` because `ownerId` only exists after `getMagazine` resolves; extending the return keeps one round-trip shape and is additive for existing destructuring callers. Mirrors the write-path lookup already in `src/domain/magazines/service.ts`. Governs R6. +- KTD8. **Copy is defined here, once, and imported.** The accessible alternative names the cell count alongside the characters — `Dot pattern to paint on a 2-cell floorplate: 0 4` — spaced characters, prefixed so it cannot be mistaken for a duplicate of the label or a truncation error, and carrying the cell count because that is the context a sighted owner reads off the magazine in their hand and a screen-reader user otherwise has no way to recover when R8 drops a prefix (R13). The unrecognized-model caveat reads `Model not recognized — confirm this floorplate has 4 dot cells before painting.` (R4). The overflow message reads `This label does not fit this magazine's floorplate.`, extended with `The model was not recognized, so the 4-cell count is unverified.` when the count came from the fallback (R9). Governs R4, R9, R13. +- KTD9. **Resolution runs in the client component inside a `useMemo` keyed on primitives.** Chosen to keep `resolveDotMatrix` a pure call co-located with the only thing that consumes it, rather than threading a resolved result across the RSC boundary for no gain — the parsed table is under a kilobyte, and freshness is not the differentiator, since `magazine-detail-view.tsx` already calls `router.refresh()` on save, which would re-run a server-side resolution just as promptly. This repo runs `reactCompiler: true`, where a freshly-built array handed to a memoized child renders stale — so the memo is keyed on `label`, `brandModel`, and `ownerMagpulMode` (primitives), never on a parent-owned object. Governs R11, R14. + +### High-Level Technical Design + +Four new modules and one changed read path. Everything left of the client boundary is pure and has no React or DB dependency, matching the `Pure — no DB, no React` convention already carried by `src/domain/magazines/validate.ts` and `display.ts`. + +```mermaid +flowchart LR + subgraph data["Checked-in data"] + TXT["src/data/magpul-glyphs.txt
(ships with zero rows)"] + RAW["src/data/raw.ts"] + end + subgraph domain["src/domain/magazines — pure"] + GL["glyphs.ts
parse + freeze"] + FP["floorplate.ts
brandModel to cell count"] + DM["dot-matrix.ts
resolveDotMatrix"] + end + subgraph server["Server"] + SVC["service.ts
getMagazine + ownerMagpulMode"] + PAGE["magazines/[id]/page.tsx"] + end + subgraph client["Client"] + VIEW["magazine-detail-view.tsx"] + COMP["dot-matrix-label.tsx
SVG, role=img"] + end + TXT --> RAW --> GL + FP --> DM + GL --> COMP + DM --> COMP + SVC --> PAGE --> VIEW --> COMP +``` + +`resolveDotMatrix` returns a discriminated union so the component branches once and every R6-R10 outcome is a named case rather than a chain of nullable fields: + +| Case | Meaning | Rs | +|---|---|---| +| `hidden` | Owner's mode off, label empty, or glyph table empty | R6, R10, KTD3 | +| `matrix` | Renderable; carries drawn characters, cells, cell count, and whether the count was verified | R7, R8, R4 | +| `unrepresentable` | Carries cell count and whether it was verified, so the caller picks the R9 wording | R9 | + +### Assumptions + +- The `magpul-mode` Playwright persona is reusable for U7 without new seed data. Its key is declared in `e2e/fixtures/user-pool.ts` (`SPEC_USER_KEYS`) and its `magpulMode` flag is enabled in `e2e/start-test-server.ts`; `e2e/fixtures/auth.ts` supplies only the generic `authTest()` helper. `e2e/magpul-mode.spec.ts` is the working usage example. +- Adding a field to `getMagazine`'s returned object breaks no caller. The only production call site is `app/(app)/magazines/[id]/page.tsx`, which destructures `{ magazine, permission }`. U4 re-audits rather than assuming. +- The glyph file parses at module load. A malformed transcription is a startup failure, not a silent partial font — U1's test over the shipped file makes that failure land in CI on the transcription PR rather than in production. + +### Sequencing + +U1, U2, and U4 are independent and can land in any order. U3 needs U1 and U2 for its types. U5 needs U1 for the cell shape and U3 for `resolveDotMatrix` and the `DotMatrixResult` type. U6 needs U3, U4, and U5. U7 needs U6. + +--- + +## Implementation Units + +### U1. Glyph table fixture and loader + +**Goal:** A checked-in, hand-editable glyph source and a parsed, frozen lookup exposed to the domain layer — shipping with zero glyph rows until the transcription lands. + +**Requirements:** R1, R2. Implements KTD2, KTD3. + +**Dependencies:** none. + +**Files:** +- `src/data/magpul-glyphs.txt` (new) — header comment explaining the format and the transcription source; zero glyph rows. +- `src/data/raw.ts` (modify) — add `MAGPUL_GLYPHS_RAW` alongside `CALIBERS_RAW` and `MANUFACTURERS_RAW`, following the existing embed-as-template-string convention. +- `src/domain/magazines/glyphs.ts` (new) — `GlyphCell` type, `GlyphTable` type, `parseGlyphTable(raw: string): GlyphTable`, and a module-level frozen `MAGPUL_GLYPHS` parsed once from `MAGPUL_GLYPHS_RAW`. +- `src/domain/magazines/__tests__/glyphs.test.ts` (new). + +**Approach:** + +1. Define the file format as one glyph per line: the character, then five space-separated 3-character rows using `#` for a painted dot and `.` for an unpainted position — e.g. `4 #.# #.# ### ..# ..#`. One line per glyph keeps the file greppable and gives a readable diff when the owner transcribes a correction. +2. Lines that are blank or begin with `#` at column zero are comments. The leading-`#` comment marker cannot collide with a glyph row, because a glyph row's first field is a single character followed by a space. +3. `parseGlyphTable` throws on a malformed row — wrong row count, wrong row width, a character outside `#.`, or a duplicate glyph character. Fail loudly at module load; a silently-dropped glyph would surface later as a spurious R9 "does not fit". +4. Empty input yields an empty table. This is a valid state, not an error — it is how the feature ships dark (KTD3). + +**Patterns to follow:** `src/data/raw.ts` and `src/domain/reference/reference.ts` — raw text embedded as a module constant so loading needs no filesystem access in the Next bundle, standalone output, or Docker; parsed once into a module-level cache. Note that `reference.ts` protects its cache by returning a fresh copy per call rather than by `Object.freeze`, and a shallow freeze would not protect a glyph cell's nested row arrays anyway; mirror the existing approach rather than adding a runtime freeze. Carry the `Pure — no DB, no React` doc-comment banner used by `src/domain/magazines/validate.ts`. + +**Test scenarios:** +- Parses a well-formed three-glyph table into a lookup keyed by character, with each cell holding five rows of three booleans. +- Throws when a glyph row has four columns instead of three. +- Throws when a glyph declares four rows instead of five. +- Throws when a row contains a character other than `#` or `.`. +- Throws when the same glyph character is declared twice. +- Returns an empty table for input that is entirely comments and blank lines. +- The shipped `src/data/magpul-glyphs.txt` parses without throwing. This is the guard that makes a bad future transcription fail CI. + +**Verification:** `bun run test` passes; `MAGPUL_GLYPHS` is empty and importing it does not throw. + +--- + +### U2. Floorplate cell-count lookup + +**Goal:** Resolve a free-text `brandModel` to a per-face cell count, reporting whether the count was matched or defaulted. + +**Requirements:** R3, R4, R5. Implements KTD4. + +**Dependencies:** none. + +**Files:** +- `src/domain/magazines/floorplate.ts` (new) — `FALLBACK_CELL_COUNT`, the ordered `MODEL_CELL_COUNTS` list, `normalizeModel`, and `resolveCellCount(brandModel): { cells: number; matched: boolean }`. +- `src/domain/magazines/__tests__/floorplate.test.ts` (new). + +**Approach:** + +1. `normalizeModel` uppercases and strips every character outside `A-Z0-9`, producing one dense token. +2. Each list entry is `{ name, tokens: readonly string[], cells }`. An entry matches when the normalized model contains every one of its tokens. First match wins, so the list is ordered most-specific-first. +3. Seed the list with the two families R5 names, keyed on a distinctive Magpul model designation alone (KTD4): the GL9 family at 2 cells (`["GL9"]`), and the 7.62x51 PMAG family at 4 cells via `["LRSR"]` for Magpul's `PMAG 20 LR/SR GEN M3` naming. Do not add a `PMAG` token to any entry — `MAGPUL` does not contain the substring `PMAG`, so requiring it would reject `Magpul GL9`. Do not add a caliber-only token (e.g. a bare `762X51`) either — caliber is brand-agnostic, so it would also match a non-Magpul magazine that happens to carry that caliber and confirm a PMAG cell count for a floorplate with no PMAG dot matrix at all. Add a comment recording that the list is expected to grow as counts are sourced, and that a wrong count is worse than a missing one because a matched entry carries no caveat. +4. An empty or whitespace-only `brandModel` returns the fallback with `matched: false` — never an error. + +**Patterns to follow:** `src/domain/firearms/constants.ts` — `as const` lists plus `isX`/`xLabel` helpers, documented as evolving through code rather than a UI. Same file shape, same doc-comment style. + +**Test scenarios:** +- `"Magpul PMAG 17 GL9"` resolves to 2 cells, matched. +- `"magpul pmag 15 gl9"` resolves to 2 cells, matched — normalization is case-insensitive. +- `"Magpul GL9"` resolves to 2 cells, matched. This is the shorthand a `PMAG`-requiring token set would have silently dropped to the 4-cell fallback, and it is the regression this unit most needs guarded. +- `"Magpul PMAG 20 LR/SR GEN M3"` resolves to 4 cells, matched — a 4-cell model is a *match*, not a fallback, which is the whole point of R5 carrying 4-cell entries. +- `"Magpul PMAG 25 7.62x51"` has no `LR/SR` marker in its model string, so it falls through to the 4-cell *unmatched* fallback rather than a caliber-only match — there is no caliber entry to match against. +- `"PTR-91 7.62X51"` and `"DPMS SR-25 7.62x51"` (non-Magpul magazines whose model strings happen to contain a caliber) resolve to the 4-cell unmatched fallback, never a confirmed match — the regression guard for the caliber-token collision KTD4 rules out. +- `"Some Unknown Brand 30rd"` resolves to 4 cells, unmatched. +- `""` and `" "` resolve to 4 cells, unmatched, without throwing. +- A model containing punctuation and extra whitespace (`"Magpul P-MAG 17 GL9"`) still matches the GL9 entry, proving normalization strips separators. +- Every entry in `MODEL_CELL_COUNTS` has at least one token and a positive cell count — a structural guard against a malformed future addition. + +**Verification:** `bun run test` passes. + +--- + +### U3. Label-to-matrix resolution + +**Goal:** One pure function that turns a magazine's label, model, and owner mode into a rendering decision covering every R6-R10 outcome. + +**Requirements:** R6, R7, R8, R9, R10. Implements KTD2, KTD3. + +**Dependencies:** U1, U2. + +**Files:** +- `src/domain/magazines/dot-matrix.ts` (new) — the `DotMatrixResult` discriminated union and `resolveDotMatrix(input)`. +- `src/domain/magazines/__tests__/dot-matrix.test.ts` (new). + +**Approach:** + +1. Signature takes `{ label, brandModel, ownerMagpulMode, glyphs }`. The glyph table is a parameter, not an import (KTD2), so the tests below run against a synthetic table today. +2. Resolve in the order the Product Contract's flowchart specifies: mode off -> `hidden`; empty label -> `hidden`; empty glyph table -> `hidden` (KTD3); then cell count via `resolveCellCount`; then font coverage; then length; then trailing-digit fallback. +3. Font coverage is checked against the *whole stored label* before truncation, per R9's first clause — a label containing an unsupported character is unrepresentable even when its trailing digits would have fit. +4. The trailing digit run is the maximal run of `0-9` at the end of the label. Absent, or longer than the cell count, yields `unrepresentable` (AE9) rather than a further truncation. +5. `matrix` and `unrepresentable` both carry `cellCount` and `cellCountVerified`, so the caller can compose R9's two-part wording without re-deriving anything. + +**Patterns to follow:** `src/domain/magazines/validate.ts` — pure, exhaustively unit-tested, doc-comment banner, test comments citing the requirement or AE each case covers (`// covers AE2`). + +**Test scenarios:** +- Covers AE6. Owner mode off returns `hidden`, whatever the label. +- Covers AE7. Empty label returns `hidden`. +- A label of only whitespace returns `hidden`. +- Covers KTD3. A non-empty label with an empty glyph table returns `hidden`, not `unrepresentable` — this is the ships-dark guarantee and the case most likely to regress. +- Covers AE1. `US04` on a 4-cell model returns `matrix` with drawn characters `U`,`S`,`0`,`4` and `cellCountVerified: true`. +- Covers AE2. `US04` on a 2-cell GL9 returns `matrix` with drawn characters `0`,`4`. +- Covers AE3. `AR12` on an unmatched model returns `matrix` with four characters and `cellCountVerified: false`. +- Covers AE4. `AR-X` on a 2-cell GL9 returns `unrepresentable` — the label is within the font but exceeds 2 cells and has no trailing digit run. +- Covers AE5. `A.1` returns `unrepresentable` because `.` is absent from the font, even on a 4-cell model where the length would have fit. +- Covers AE9. `1234` on a 2-cell GL9 returns `unrepresentable` — the trailing digit run is the whole label and still overflows. +- Combined case, no AE: an *unmatched* model whose label is unrepresentable returns `unrepresentable` with `cellCountVerified: false`, so U6 can add R9's "the 4-cell count is unverified" clause. R9 specifies this but no AE exercises it. +- A label exactly equal to the cell count renders every character — the boundary between R7 and R8. +- A label whose trailing digit run exactly equals the cell count renders that run — the boundary between R8 and R9. +- Lowercase characters in a pre-existing stored label are treated by whatever `normalizeMagpulLabel` in `src/domain/magazines/constants.ts` already does; assert the chosen behavior explicitly rather than leaving it implicit. + +**Verification:** `bun run test` passes with every acceptance example above represented by a named test. + +--- + +### U4. Owner-scoped Magpul mode on the detail read path + +**Goal:** The detail page resolves the magazine *owner's* Magpul mode instead of the viewer's, and every consumer of that value moves with it. + +**Requirements:** R6. Implements KTD7. + +**Dependencies:** none. + +**Files:** +- `src/domain/magazines/service.ts` (modify) — extend `getMagazine`'s return to `{ magazine, permission, ownerMagpulMode }`. +- `app/(app)/magazines/[id]/page.tsx` (modify) — pass the resolved owner flag instead of `user.magpulMode`. +- `app/(app)/magazines/magazine-detail-view.tsx` (modify) — rename the prop from `magpulMode` to `ownerMagpulMode`. +- `src/domain/magazines/__tests__/service.test.ts` (modify). + +**Approach:** + +1. In `getMagazine`, after the magazine row resolves, read the owner's flag with the same query already used on the write path — `select({ magpulMode: user.magpulMode }).from(user).where(eq(user.id, ownerId))`. Mirror the write path's loud failure: a missing owner row is corrupt state, not "mode off". +2. Rename the prop rather than silently repointing it. `magpulMode` -> `ownerMagpulMode` forces every call site to be visited, which is the point — the value's meaning changes and a silent repoint would leave the change invisible in review. +3. **The edit form's label mask moves to the same flag, and this changes no behavior today.** `MagazineDetailView` feeds the flag to the mask as well as to the matrix, and server-side validation in `createMagazine`/`updateMagazine` already keys on the owner's mode — but magazine editing is owner-only (`authorizeOwnerOnlyUpdate` in `src/auth/authorize.ts`, and `isOwner = permission === "owner"` gates the edit UI), so whenever the form renders, the viewer *is* the owner and the two flags are already equal. Repointing the mask is therefore inert, not a fix: it removes a latent divergence that would appear only if magazine editing later opened to edit-grantees. Because no mask behavior changes, this does not cross the Product Contract's "form input mask stays as shipped" boundary. +4. Audit every caller of `getMagazine` before changing its return type. There is one production call site today. + +**Patterns to follow:** `src/domain/magazines/service.ts` lines ~115-124 and ~186-194 — the existing owner-`magpulMode` lookup, including its comment explaining why a missing row throws. + +**Test scenarios:** +- `getMagazine` returns `ownerMagpulMode: true` for a magazine whose owner has the flag on, when read by that owner. +- Integration: a grantee with view permission reading a magazine whose **owner** has Magpul mode on receives `ownerMagpulMode: true` even though the grantee's own flag is off. This is the requirement, and the case the current code gets backwards. +- The inverse: owner's flag off, grantee's flag on, returns `false`. +- `getMagazine` still throws `NotFoundError` for a magazine the actor cannot see — the added query must not open a visibility hole. +- Existing `getMagazine` tests still pass unchanged, proving the return extension is additive. + +Give each test its own owner via `src/test-support/factories.ts` rather than asserting over rows left by earlier tests. + +**Verification:** `bun run test` passes; `bun run typecheck` passes, which is what proves every call site of the renamed prop was updated. + +--- + +### U5. Dot-matrix tokens and the `DotMatrixLabel` component + +**Goal:** An SVG component that draws a resolved matrix, theme-aware and accessible, with the contrast floor locked by a test. + +**Requirements:** R11, R12, R13, R15. Implements KTD1, KTD5, KTD6, KTD9. + +**Dependencies:** U1, U3. + +**Files:** +- `app/globals.css` (modify) — add `--dot-painted` and `--dot-unpainted` to both the `[data-theme="dark"]` and `[data-theme="light"]` blocks, and bridge them in the existing `@theme inline` block. +- `app/(app)/magazines/dot-matrix-label.tsx` (new) — the presentational component. +- `src/domain/magazines/__tests__/dot-matrix-contrast.test.ts` (new) — the R15 guard. + +**Approach:** + +1. Define the two tokens as aliases of existing tokens rather than new colors: `--dot-painted: var(--foreground)` and `--dot-unpainted: var(--muted-foreground)` in each theme block (KTD5). Naming them separately means a future retune of the dot colors does not require touching text tokens, and the contrast test below has a stable name to assert against. Use the `[data-theme="..."]` attribute selectors this repo already uses — **not** `prefers-color-scheme`, which would bypass the `next-themes` toggle. +2. The component takes the `DotMatrixResult` `matrix` case and renders one `` with a `viewBox` sized from the number of cells actually drawn, not the floorplate's cell count. A label shorter than the floorplate leaves the remaining capacity undrawn, and sizing off the untouched cell count would leave blank space where those cells would have been. The `aria-label` still names the floorplate's full cell count per KTD8 — the two are deliberately different, and the code carries a comment saying so. +3. Geometry constants per KTD6 — 16px pitch, 10px painted diameter, 6px unpainted diameter, 16px inter-cell gap — as named constants in the component file, not literals scattered through the JSX. The two diameters are what carry R15's "visually distinct from one another" clause, so a painted and an unpainted circle differ in radius as well as fill. +4. Drive fill from the tokens via Tailwind utilities generated by `@theme inline`. Because Tailwind v4 silently no-ops an unknown utility class, verify visually in both themes; nothing will error on a typo. +5. The component calls `resolveDotMatrix` inside a `useMemo` keyed on `label`, `brandModel`, and `ownerMagpulMode` (KTD9). Do not accept a pre-built cell array as a prop. + +**Patterns to follow:** `components/ui/theme-toggle.tsx` and `app/(app)/firearms/[id]/firearm-photos.tsx` for descriptive, dynamic `aria-label` phrasing. Nothing in the repo renders a grid or uses `role="img"` today — this is the first, so keep it conventional rather than clever. + +**Test scenarios:** +- Contrast guard: parse `app/globals.css`, extract `--foreground`, `--muted-foreground`, and `--card` for each theme block, compute the WCAG relative-luminance contrast ratio, and assert each dot token clears 3:1 against `--card` in both themes. +- The same test asserts `--dot-painted` and `--dot-unpainted` are declared in *both* theme blocks — a token present in only one theme is the exact failure Tailwind will not report. +- Assert the painted and unpainted dot diameters differ. This is the mechanical guard on R15's "visually distinct from one another" clause; do not assert a contrast ratio between the two tokens, which measures only 2.67:1 in the dark theme (KTD5). + +**Test expectation for the component itself:** covered by U7's e2e, not by a component unit test. This repo has no component-test harness, and adding one for a single presentational component is not warranted; the domain logic it renders is already exhaustively covered by U3. + +**Verification:** `bun run test` passes including the contrast guard; the component renders correctly in both themes at a 320px viewport width. + +--- + +### U6. Detail-view integration and messaging + +**Goal:** The magazine detail view shows the matrix, the R4 caveat, and the R9 messages in the right combinations, without disturbing the existing label text. + +**Requirements:** R4, R9, R13, R14. Implements KTD8. + +**Dependencies:** U3, U4, U5. + +**Files:** +- `app/(app)/magazines/magazine-detail-view.tsx` (modify) — render the matrix near the existing `Label` `DetailRow`. +- `app/(app)/magazines/dot-matrix-label.tsx` (modify) — export the message strings, or add a small sibling module if the component file grows past the repo's file-size convention. + +**Approach:** + +1. Branch once on the `DotMatrixResult` kind. `hidden` renders nothing at all — no empty grid, no placeholder, no message (R10, KTD3). +2. `matrix` renders the SVG, plus the R4 caveat beneath it when `cellCountVerified` is false. +3. `unrepresentable` renders no SVG and the R9 message, extended with the unverified clause when `cellCountVerified` is false (KTD8). +4. The existing `label` `DetailRow` stays exactly as it is. R13 requires the label to remain readable text, and AE8 asserts both are present. +5. Render nothing on the list row — R14 scopes this to the detail view, and `app/(app)/magazines/magazines-view.tsx` is untouched. + +**Patterns to follow:** the existing `DetailRow` usage in `magazine-detail-view.tsx`; `components/ui/detail-row.tsx` for the label/value shape. + +**Test scenarios:** covered end-to-end in U7. The branching logic itself is a direct switch over U3's union, which U3's tests already cover exhaustively; duplicating them here as component tests would restate coverage without adding signal. + +**Verification:** `bun run typecheck` and `bun run lint` pass; the detail view renders unchanged for a magazine whose owner has Magpul mode off. + +--- + +### U7. End-to-end coverage + +**Goal:** Prove in a real browser that the feature is wired correctly and inert while the glyph table is empty, and that the existing Magpul-mode flows still pass. + +**Requirements:** R6, R13, R14. + +**Dependencies:** U6. + +**Files:** +- `e2e/magazine-dot-matrix.spec.ts` (new). + +**Approach:** + +Assert what is true with an empty glyph table — that is the shipped state, so these are real assertions, not placeholders. Write them so the transcription PR extends this file rather than rewriting it. Reuse the `magpul-mode` persona from `e2e/fixtures/auth.ts`. Select by ARIA role, accessible name, and visible text only; this repo forbids `data-testid`. + +**Test scenarios:** +- A magazine detail page for an owner with Magpul mode on shows the label as text and exposes no `img`-role graphic named `Dot pattern to paint`, because the glyph table has no rows. This is the ships-dark guarantee, verified through the real render path. +- The same page renders without a console error and without horizontal overflow at a 320px viewport, asserted the way `e2e/responsive-overflow.spec.ts` already does it — a measured `document.documentElement.scrollWidth` against the viewport width, not a visual judgment. A prose assertion like "no layout break" is not implementable and passes trivially on an empty page. +- A magazine detail page for an owner with Magpul mode off is byte-for-byte unaffected by this feature. +- The existing `e2e/magpul-mode.spec.ts` suite still passes, proving U4's prop rename did not break the label input mask. + +**Note on deferred coverage:** assertions on a *rendered* matrix — AE1 through AE9 in a browser — land with the glyph transcription, since no matrix can render before it. They are listed under Scope Boundaries as deferred follow-up work, not skipped tests. Do not add a skipped or conditionally-disabled spec for them. + +**Verification:** `bun run test:e2e` passes. Never invoke bare `bun test` — it mis-loads Playwright specs and reports phantom failures. + +--- + +## Verification Contract + +**The gate:** `just ci-check` must pass before every commit. This is a hard project rule — no `--no-verify`, no skipping, no deferring a red gate to a follow-up. + +| Command | Covers | Notes | +|---|---|---| +| `bun run lint` | Biome. Not ESLint or Prettier. | | +| `bun run typecheck` | `tsc --noEmit`. Proves U4's prop rename reached every call site. | | +| `bun run test` | Unit and integration (`bun:test`). | Requires Docker — Testcontainers starts an ephemeral migrated Postgres via `src/test-support/preload.ts`. | +| `bun run test:e2e` | Playwright, `e2e/`. | Requires Docker. | +| `just ci-check` | All of the above. | The commit gate. | + +Never run bare `bun test`: it mis-loads the Playwright specs in `e2e/` and reports phantom failures that look like regressions in this feature. + +Requirement-to-proof map: + +- R1, R2 — U1's parser tests, plus the test that parses the shipped fixture. +- R3, R4, R5 — U2's lookup tests, including a 4-cell model resolving as *matched*. R4's **caveat wording** is not covered; no test in this plan renders it. +- R6 — U4's integration test where the grantee's flag and the owner's flag disagree. +- R7, R8, R9, R10 — U3's tests, one per acceptance example plus the boundary and combined cases. These prove the resolver's structured output. R9's **message wording** is not covered. +- R11, R12, R15 — U5's contrast guard over `app/globals.css`, in both themes, plus the differing-diameter assertion. +- R13 — U7 asserts the exact accessible name KTD8 specifies (`Dot pattern to paint on a 2-cell floorplate: 0 4`) against a rendered matrix, and that the stored label still reads as text alongside it. +- R14 — verified structurally: `app/(app)/magazines/magazines-view.tsx` appears in no unit's Files list. + +R4's caveat wording and R9's message wording remain unverified by an automated test. Do not read a green `just ci-check` as proof that either reads correctly. + +--- + +## Definition of Done + +Global: + +- `just ci-check` passes. +- The glyph fixture ships with all 36 glyphs (`0`-`9`, `A`-`Z`) transcribed; a fresh detail-view render shows the matrix wherever the Product Contract's R6-R10 rules say it should, and nothing otherwise — no matrix, no empty grid, no caption when mode is off, the label is empty, or the label is unrepresentable. +- No `data-testid` was added anywhere in the app. +- No test was skipped, disabled, or gated behind an environment variable. In particular, no `process.env.DATABASE_URL ? describe : describe.skip` idiom was reintroduced. +- Dead ends from abandoned approaches are removed from the diff, not left commented out. +- No schema migration was added — this feature is presentation over the existing `label`. + +Per unit: + +- U1 — `parseGlyphTable` throws on every malformed shape listed; the shipped fixture parses; `MAGPUL_GLYPHS` holds all 36 transcribed glyphs. +- U2 — both seed families resolve as *matched*, and an unknown model resolves to 4 cells *unmatched*. +- U3 — every acceptance example AE1-AE9 has a named test, plus the unverified-and-unrepresentable combination and both R7/R8 and R8/R9 boundaries. +- U4 — `bun run typecheck` passes after the prop rename, and the owner-versus-viewer integration test passes in both directions. +- U5 — the contrast guard asserts ≥3:1 for both dot tokens against `--card` in both themes, fails if either token is missing from either theme block, and asserts the painted and unpainted diameters differ. +- U6 — the `hidden` case renders nothing; the existing label `DetailRow` is unchanged; `magazines-view.tsx` is untouched. +- U7 — the new spec passes and `e2e/magpul-mode.spec.ts` still passes. + +--- + +## Deferred / Open Questions + +### From 2026-08-02 review + +- **No minimum dot size for a tracing-accuracy feature** — Requirements, presentation and accessibility (P2, design-lens, confidence 75). **Resolved in planning:** KTD6 fixes the geometry at 16px pitch / 10px painted diameter / 6px unpainted diameter / 16px inter-cell gap, above a 6px-diameter, 12-14px-pitch floor derived from dot-peen marking practice and dot-matrix display convention, and sized so the 4-cell case fits the detail card's real 248px content width at a 320px viewport. The width budget the finding flagged as depending on the one-face question is settled with it: one face means at most 4 cells, never eight. + +### From 2026-08-02 planning review + +- **Fixed geometry leaves desktop width unused** — Planning Contract, KTD6 (P2, design-lens, confidence 75) + + The matrix renders at the same 216x74 px on a 1440px display as on a phone, so the extra room a large screen offers does nothing for the tracing accuracy this feature exists to serve. KTD6's argument against fluid sizing only rules out scaling *down* past the legibility floor; it does not address a floor-anchored rule that grows upward and never shrinks. Deferred rather than resolved: a single fixed size is the simplest thing that is correct at every width, and growth can be added later without changing the resolver, the tokens, or any requirement. Revisit once the glyph transcription lands and there is a real matrix to judge at size. diff --git a/docs/residual-review-findings/20-render-magazine-label-as-a-magpul-paint-pen-dot-matrix-in-the-detail-view.md b/docs/residual-review-findings/20-render-magazine-label-as-a-magpul-paint-pen-dot-matrix-in-the-detail-view.md new file mode 100644 index 00000000..bb9e2ebf --- /dev/null +++ b/docs/residual-review-findings/20-render-magazine-label-as-a-magpul-paint-pen-dot-matrix-in-the-detail-view.md @@ -0,0 +1,99 @@ +# Residual Review Findings + +Branch: `20-render-magazine-label-as-a-magpul-paint-pen-dot-matrix-in-the-detail-view` +Plan: `docs/plans/2026-08-02-001-feat-magazine-dot-matrix-label-plan.md` +Source: `ce-code-review` (7 reviewers) + `ce-simplify-code` (3 reviewers), 2026-08-02. + +These are the findings that were **not** applied. Everything else the review surfaced landed in +commits `85f2575` (simplification) and `83cc002` (review fixes). No tracker tickets were filed; +this committed record is the durable sink. + +Almost all of it converges on one thing: **the glyph table ships empty, so the render path has no +production or test exercise at all.** These items become both testable and worth revisiting in the +same follow-up that transcribes Magpul's diagram. + +--- + +## Not applied + +- **P1 — The ships-dark e2e cannot tell "suppressed" from "unwired".** `e2e/magazine-dot-matrix.spec.ts:94` + (adversarial, advisory, owner: human) + + The only matrix-specific assertion is `getByRole("img", { name: /Dot pattern to paint/ })` having + count 0. `DotMatrixLabel` returns `null` in the hidden case, so there is no DOM difference between + "the component ran and correctly resolved to hidden" and "the component was never invoked". Deleting + the `` line from `magazine-detail-view.tsx` leaves this spec fully green. Until the + font lands there is no positive signal to assert against, which is why it was not fixed here — but it + means a wiring regression between now and the transcription PR would ship silently, and this spec is + exactly the safety net that PR will lean on. + +- **P2 — The rendered output is entirely unverified.** `app/(app)/magazines/dot-matrix-label.tsx` + (adversarial + correctness testing gaps, owner: human) + + No test at any level exercises the component's markup: the SVG geometry, the per-dot keys, the + `unrepresentable` branch, or the `cellCountVerified` caveat branch. `buildAriaLabel` and + `buildDoesNotFitMessage` are never called by a test. `resolveDotMatrix` underneath is covered + exhaustively against synthetic fixtures, so the gap is precisely the translation from result to + markup — new, unexercised surface the moment the real table ships. This is the same gap that let the + canvas-width bug (fixed in `83cc002`) sit unnoticed through implementation. + + Related: AE8/R13's accessible-name wording, R4's caveat text, and R9's message text are asserted + nowhere. The plan's own Verification Contract already records these three as unverified. + +- **P3 — `glyphs.ts` throws at module load with no runtime fallback.** `src/domain/magazines/glyphs.ts:105` + (adversarial, advisory, owner: human) + + `MAGPUL_GLYPHS` is parsed at import time and this module is reachable from the magazine detail route, + so a malformed fixture hard-crashes that route rather than degrading to "dot matrix unavailable". + Currently well-guarded: `glyphs.test.ts` asserts the shipped fixture parses, and `just ci-check` gates + every commit. Exposure requires a hand-edit that bypasses CI. Left as-is because fail-loud on a + corrupt font is the right default for a feature whose whole job is telling someone what to paint + permanently onto hardware. + +- **P3 — The shared overflow fixture is weaker than the check it was extracted from.** + `e2e/fixtures/overflow.ts` (adversarial residual, owner: human) + + `expectNoHorizontalOverflow` asserts `scrollWidth <= clientWidth + 1`. `responsive-overflow.spec.ts` + additionally probes `maxScrollX` for real scrollability, and kept its own inline version because + swapping in the shared helper would have split one `page.evaluate` into two. A future spec adopting + the shared helper on the assumption of parity would get the weaker guarantee. + +## Deferred to the repo owner (from PR #90 review) + +- **`unrepresentable` conflates "unsupported character" with "too long".** `src/domain/magazines/dot-matrix.ts` + (CodeRabbit, PR #90, owner: human) + + The `unrepresentable` variant carries only `cellCount` and `cellCountVerified`, so the resolver returns the + same shape whether the label overflows the floorplate or merely contains a character outside the glyph + table. The view then always renders "This label does not fit this magazine's floorplate" — factually wrong + for a short label that fails only on font coverage, which is exactly AE5 (`A.1` on a 4-cell PMAG). + + **Not fixed autonomously**, because the message is specified product behavior: R9 and AE5 both define a + single message for both causes. Changing it is a product-copy decision. + + Recommendation: add `reason: "unsupportedCharacter" | "doesNotFit"` to the `unrepresentable` variant, give + the unsupported-character case its own message, and amend R9/AE5 to match. The discriminant was **not** + added preemptively — with the message unchanged it would have no consumer. + + Nothing is user-visible yet: the empty glyph table short-circuits to `hidden` first, so this surfaces only + once the font is transcribed. + +- **`src/data/calibers.txt` and `manufacturers.txt` have the same untested drift** that PR #90 fixed for the + glyph fixture. `reference.test.ts` asserts against the caches parsed from `raw.ts` and never reads the + `.txt` files, so editing one and forgetting to regenerate would pass CI while production used the stale + embedded string. Left alone as out of scope; the glyph fixture now has the pattern to copy. + +## Checked and cleared + +Recorded so a later reader does not re-derive them: + +- **Security: no findings.** The authorization gate in `getMagazine` still runs strictly before both the + owner lookup and `attachCompatibility`, including after the `Promise.all` refactor. `NotFoundError` + semantics are preserved, so the 404 path stays indistinguishable. No injection path — only `` + elements derive from the label, and the one string reaching markup goes through a React-escaped + attribute. +- **Exposing the owner's `magpulMode` to a grantee** is deliberate, not a leak: R6 requires rendering + against the owner's setting, and the flag was already indirectly observable before this change. +- **Cross-brand token collision** was the P1 fixed in `83cc002`. The residual risk is that a *future* + addition to `MODEL_CELL_COUNTS` could reintroduce it with a token that is a substring of some other + product line. The list now carries a comment banning caliber tokens for this reason. diff --git a/e2e/fixtures/console-errors.ts b/e2e/fixtures/console-errors.ts new file mode 100644 index 00000000..1c97025e --- /dev/null +++ b/e2e/fixtures/console-errors.ts @@ -0,0 +1,28 @@ +import type { Page } from "@playwright/test"; + +/** + * Shared console/page-error tracker (originally duplicated between + * theme.spec.ts and magazine-dot-matrix.spec.ts). + * + * Ignores only the specific favicon 404 some production builds emit — a + * narrow allowlist so genuine runtime errors (incl. net::ERR_*) still fail. + */ +const BENIGN_CONSOLE_PATTERNS = [/favicon\.ico/i]; + +function isBenignConsoleText(text: string): boolean { + return BENIGN_CONSOLE_PATTERNS.some((pattern) => pattern.test(text)); +} + +/** Attach console/page-error listeners before any navigation the caller wants covered. */ +export function trackConsoleErrors(page: Page): string[] { + const errors: string[] = []; + page.on("console", (message) => { + if (message.type() === "error" && !isBenignConsoleText(message.text())) { + errors.push(message.text()); + } + }); + page.on("pageerror", (error) => { + if (!isBenignConsoleText(error.message)) errors.push(error.message); + }); + return errors; +} diff --git a/e2e/fixtures/overflow.ts b/e2e/fixtures/overflow.ts new file mode 100644 index 00000000..5fec845a --- /dev/null +++ b/e2e/fixtures/overflow.ts @@ -0,0 +1,23 @@ +import { expect, type Page } from "@playwright/test"; + +/** + * Fails if the document is wider than the viewport (originally inlined in + * magazine-dot-matrix.spec.ts; the same formula, `+1` tolerance, and message + * template are also used inline in responsive-overflow.spec.ts). The `+1` + * tolerance absorbs sub-pixel rounding across browsers. + */ +const OVERFLOW_TOLERANCE_PX = 1; + +export async function expectNoHorizontalOverflow( + page: Page, + contextLabel: string, +): Promise { + const overflow = await page.evaluate(() => ({ + documentWidth: document.documentElement.scrollWidth, + viewportWidth: document.documentElement.clientWidth, + })); + expect( + overflow.documentWidth, + `${contextLabel} has a ${overflow.documentWidth}px document in a ${overflow.viewportWidth}px viewport`, + ).toBeLessThanOrEqual(overflow.viewportWidth + OVERFLOW_TOLERANCE_PX); +} diff --git a/e2e/magazine-dot-matrix.spec.ts b/e2e/magazine-dot-matrix.spec.ts new file mode 100644 index 00000000..564a73de --- /dev/null +++ b/e2e/magazine-dot-matrix.spec.ts @@ -0,0 +1,172 @@ +import type { Page } from "@playwright/test"; +import { authTest, expect } from "./fixtures/auth"; +import { trackConsoleErrors } from "./fixtures/console-errors"; +import { expectNoHorizontalOverflow } from "./fixtures/overflow"; + +/** + * Coverage for the Magpul dot-matrix label (issue #20, U7; R6, R13, R14). + * + * `src/data/magpul-glyphs.txt` now carries all 36 transcribed Magpul glyphs + * (`0`-`9`, `A`-`Z`), so `resolveDotMatrix` renders a real matrix and + * `DotMatrixLabel` draws it. These assertions exercise the acceptance + * examples from `docs/plans/2026-08-02-001-feat-magazine-dot-matrix-label-plan.md`: + * - AE1 — a 4-cell magazine renders every character of a label that fits. + * - AE2 / AE8 — a 2-cell GL9 magazine renders only the trailing digits when + * the label overflows, with an accessible name naming exactly what was + * drawn, and the stored label still reads in full as text. + * - AE6 — Magpul mode off suppresses the matrix entirely, whatever the + * label contains. + * + * Two personas, both reused rather than newly seeded: + * - "magpul-mode" (already seeded with `magpulMode: true` by the launcher) + * for the on cases. That account is also used by `magpul-mode.spec.ts`, + * whose first step depends on a cold-start "Start with a magazine" empty + * state (no firearms, no magazines). This spec restores that invariant by + * deleting every magazine it creates before each test ends, so the two + * specs stay compatible regardless of which runs first in a full-suite + * run. + * - "theme" (plain, `magpulMode` off by default) for the off case — chosen + * because `theme.spec.ts` only needs the theme toggle to be visible on + * `/magazines` and never asserts on magazine/firearm counts, so a + * create-then-delete round trip here cannot disturb it. Also restored by + * deletion at the end. + */ + +const magpulTest = authTest("magpul-mode"); +magpulTest.describe.configure({ retries: 0 }); + +const plainTest = authTest("theme"); +plainTest.describe.configure({ retries: 0 }); + +/** + * Creates one magazine via the real add-magazine form and opens its detail + * page. Handles both cold-start empty-state button variants (no firearms yet + * vs. firearms but no magazines) since which one a given persona sees depends + * on state left behind by other specs sharing that persona. + */ +async function addMagazineAndOpenDetail( + page: Page, + brandModel: string, + label: string, +): Promise { + await page.goto("/magazines"); + await page + .getByRole("button", { + name: /Add your first magazine|Start with a magazine|^Add magazine$/, + }) + .click(); + const form = page.locator("form"); + await form.getByLabel(/^Brand \/ model/).fill(brandModel); + await form.getByLabel(/^Caliber/).fill("5.56 NATO"); + if (label !== "") { + await form.getByLabel("Label", { exact: true }).fill(label); + } + await page.getByRole("button", { name: "Add magazine" }).click(); + await expect(page.getByText("Magazine seated").first()).toBeVisible(); + + await page.goto("/magazines"); + await page.getByRole("link", { name: brandModel }).click(); + await expect( + page.getByRole("heading", { level: 1, name: brandModel }), + ).toBeVisible(); +} + +/** Deletes the magazine from its own detail page, restoring the persona's + * magazine list to empty for whichever spec shares this account. */ +async function deleteFromDetailPage(page: Page): Promise { + await page.getByRole("button", { name: "Delete" }).click(); + const dialog = page.getByRole("alertdialog"); + await expect(dialog).toBeVisible(); + await dialog.getByRole("button", { name: "Delete" }).click(); + await expect(page).toHaveURL(/\/magazines$/); +} + +magpulTest( + "AE1: a 4-cell magazine with Magpul mode on renders every character of a label that fits, and the label still reads as text", + async ({ page }) => { + // "LR/SR" normalizes to a substring containing the floorplate.ts "LRSR" + // token, so this brandModel resolves to a *matched* (verified) 4-cell + // floorplate rather than the unrecognized-model fallback. + const brandModel = "Magpul PMAG 20 LR/SR GEN M3"; + const label = "US04"; + + // Attached before the magazine is created and before the first + // navigation, so it captures console errors from the whole add-magazine + // flow and the initial detail render, not just the reload below. + const consoleErrors = trackConsoleErrors(page); + + try { + await addMagazineAndOpenDetail(page, brandModel, label); + + await expect(page.getByText(label, { exact: true })).toBeVisible(); + await expect( + page.getByRole("img", { + name: "Dot pattern to paint on a 4-cell floorplate: U S 0 4", + exact: true, + }), + ).toBeVisible(); + + await page.setViewportSize({ width: 320, height: 844 }); + await page.reload(); + await expect( + page.getByRole("heading", { level: 1, name: brandModel }), + ).toBeVisible(); + + await expectNoHorizontalOverflow(page, "magazine detail page at 320px"); + expect(consoleErrors).toEqual([]); + } finally { + // This persona is shared with magpul-mode.spec.ts, whose first step + // requires a zero-magazine cold start; an assertion failure above must + // not leak this magazine and cause a confusing failure over there. + await deleteFromDetailPage(page); + } + }, +); + +magpulTest( + "AE2/AE8: a 2-cell GL9 magazine with Magpul mode on renders only the trailing digits of an overflowing label, with an accessible name naming exactly what was drawn", + async ({ page }) => { + // "GL9" matches the floorplate.ts GL9-family token, resolving to a + // matched 2-cell floorplate. The 4-character label overflows it, so only + // the trailing digit run ("04") is drawn (R8). + const brandModel = "Magpul GL9"; + const label = "US04"; + + try { + await addMagazineAndOpenDetail(page, brandModel, label); + + // The stored label still renders in full as text (R13) even though + // the matrix draws only its trailing digits. + await expect(page.getByText(label, { exact: true })).toBeVisible(); + await expect( + page.getByRole("img", { + name: "Dot pattern to paint on a 2-cell floorplate: 0 4", + exact: true, + }), + ).toBeVisible(); + } finally { + await deleteFromDetailPage(page); + } + }, +); + +plainTest( + "AE6: magazine detail page with Magpul mode off is unaffected by the dot-matrix feature", + async ({ page }) => { + const brandModel = "Non-Magpul Coverage Mag"; + const label = "raw-label"; + + try { + await addMagazineAndOpenDetail(page, brandModel, label); + + // Off mode never applies the Magpul input mask (magazine-form.tsx + // handleLabelChange), so the label round-trips exactly as typed. + await expect(page.getByText(label, { exact: true })).toBeVisible(); + await expect( + page.getByRole("img", { name: /Dot pattern to paint/ }), + ).toHaveCount(0); + } finally { + await deleteFromDetailPage(page); + } + }, +); diff --git a/e2e/magazine-inventory-filter.spec.ts b/e2e/magazine-inventory-filter.spec.ts index 94e8286d..f81f75f6 100644 --- a/e2e/magazine-inventory-filter.spec.ts +++ b/e2e/magazine-inventory-filter.spec.ts @@ -59,7 +59,13 @@ async function navigateCalendarMonths(page: Page, delta: number) { * responsible for the month already being in view (`navigateCalendarMonths`). */ async function selectCalendarDay(page: Page, date: Date) { + // Scope to the grid for `date`'s own month. The range picker shows two + // months at once and react-day-picker renders outside-days, so a day near a + // month boundary carries the same accessible name in both grids — a + // page-wide lookup is a strict-mode violation on those dates, and which + // dates those are depends on when the suite runs. await page + .getByRole("grid", { name: format(date, "MMMM") }) .getByRole("button", { name: format(date, "PPPP"), exact: true }) .click(); } diff --git a/e2e/theme.spec.ts b/e2e/theme.spec.ts index ee584a29..34f23f74 100644 --- a/e2e/theme.spec.ts +++ b/e2e/theme.spec.ts @@ -1,4 +1,5 @@ import { authTest, expect } from "./fixtures/auth"; +import { trackConsoleErrors } from "./fixtures/console-errors"; /** * Three-way theme toggle (R8). next-themes resolves data-theme to only light or @@ -23,18 +24,7 @@ test("theme toggle cycles the three modes without console errors", async ({ }) => { // Ignore only the specific favicon 404 some production builds emit — a // narrow allowlist so genuine runtime errors (incl. net::ERR_*) still fail. - const BENIGN = [/favicon\.ico/i]; - const isBenign = (text: string) => - BENIGN.some((pattern) => pattern.test(text)); - const consoleErrors: string[] = []; - page.on("console", (message) => { - if (message.type() === "error" && !isBenign(message.text())) { - consoleErrors.push(message.text()); - } - }); - page.on("pageerror", (error) => { - if (!isBenign(error.message)) consoleErrors.push(error.message); - }); + const consoleErrors = trackConsoleErrors(page); await page.emulateMedia({ colorScheme: "light" }); await page.goto("/magazines"); diff --git a/src/data/magpul-glyphs.txt b/src/data/magpul-glyphs.txt new file mode 100644 index 00000000..5180a18d --- /dev/null +++ b/src/data/magpul-glyphs.txt @@ -0,0 +1,63 @@ +# Magpul PMAG Gen M3 dot-matrix glyph font (#20). +# +# Format: one glyph per line — +# +# where each row is exactly 3 characters wide: `#` marks a painted dot, `.` +# marks an unpainted position. Example shape (illustrative only, not an +# authoritative transcription): +# 4 #.# #.# ### ..# ..# +# +# Source of truth: Magpul's published PMAG Gen M3 dot-matrix diagram — +# https://magpul.com/media/wysiwyg/Instructions/Magpul_Dot_Matrix.pdf +# Cross-check every transcribed row against the community reference below +# before treating it as authoritative; resolve any discrepancy against the +# Magpul PDF, which wins ties — +# https://www.ar15.com/media/viewFile.html?i=36785 +# +# Transcribed from the Magpul PDF by rendering it at 300 DPI, recovering the +# dot lattice, and sampling every position -- then verified against the sheet +# by eye. Magpul's own sheet orders the digits 1-9 then 0; order here is +# irrelevant since the parser keys on the character. The sheet carries NO +# hyphen and no punctuation, so a stored label containing one is +# unrepresentable (R9). Blank lines and lines beginning with `#` in column +# zero are comments and are skipped by the parser +# (src/domain/magazines/glyphs.ts). +# +# Covers R1 (glyph coverage: 0-9, A-Z, hyphen) and R2 (fixed 3x5 dot cell). + +0 ### #.# #.# #.# ### +1 ##. .#. .#. .#. ### +2 ### ..# ### #.. ### +3 ### ..# ### ..# ### +4 #.# #.# ### ..# ..# +5 ### #.. ### ..# ### +6 ### #.. ### #.# ### +7 ### ..# ..# ..# ..# +8 ### #.# ### #.# ### +9 ### #.# ### ..# ..# +A ### #.# ### #.# #.# +B ##. #.# ##. #.# ##. +C ### #.. #.. #.. ### +D ##. #.# #.# #.# ##. +E ### #.. ### #.. ### +F ### #.. ### #.. #.. +G .## #.. ### #.# .#. +H #.# #.# ### #.# #.# +I ### .#. .#. .#. ### +J ..# ..# ..# #.# .#. +K #.# #.# ##. #.# #.# +L #.. #.. #.. #.. ### +M #.# ### #.# #.# #.# +N ##. #.# #.# #.# #.# +O .#. #.# #.# #.# .#. +P ### #.# ### #.. #.. +Q ### #.# #.# ### ..# +R ### #.# ##. #.# #.# +S ### #.. ### ..# ### +T ### .#. .#. .#. .#. +U #.# #.# #.# #.# ### +V #.# #.# #.# #.# .#. +W #.# #.# #.# ### #.# +X #.# #.# .#. #.# #.# +Y #.# #.# .#. .#. .#. +Z ### ..# .#. #.. ### diff --git a/src/data/raw.ts b/src/data/raw.ts index 55d9cf29..a9bd8f8c 100644 --- a/src/data/raw.ts +++ b/src/data/raw.ts @@ -116,6 +116,71 @@ Handgun Cartridge 9mm Makarov `; +export const MAGPUL_GLYPHS_RAW = `# Magpul PMAG Gen M3 dot-matrix glyph font (#20). +# +# Format: one glyph per line — +# +# where each row is exactly 3 characters wide: \`#\` marks a painted dot, \`.\` +# marks an unpainted position. Example shape (illustrative only, not an +# authoritative transcription): +# 4 #.# #.# ### ..# ..# +# +# Source of truth: Magpul's published PMAG Gen M3 dot-matrix diagram — +# https://magpul.com/media/wysiwyg/Instructions/Magpul_Dot_Matrix.pdf +# Cross-check every transcribed row against the community reference below +# before treating it as authoritative; resolve any discrepancy against the +# Magpul PDF, which wins ties — +# https://www.ar15.com/media/viewFile.html?i=36785 +# +# Transcribed from the Magpul PDF by rendering it at 300 DPI, recovering the +# dot lattice, and sampling every position -- then verified against the sheet +# by eye. Magpul's own sheet orders the digits 1-9 then 0; order here is +# irrelevant since the parser keys on the character. The sheet carries NO +# hyphen and no punctuation, so a stored label containing one is +# unrepresentable (R9). Blank lines and lines beginning with \`#\` in column +# zero are comments and are skipped by the parser +# (src/domain/magazines/glyphs.ts). +# +# Covers R1 (glyph coverage: 0-9, A-Z, hyphen) and R2 (fixed 3x5 dot cell). + +0 ### #.# #.# #.# ### +1 ##. .#. .#. .#. ### +2 ### ..# ### #.. ### +3 ### ..# ### ..# ### +4 #.# #.# ### ..# ..# +5 ### #.. ### ..# ### +6 ### #.. ### #.# ### +7 ### ..# ..# ..# ..# +8 ### #.# ### #.# ### +9 ### #.# ### ..# ..# +A ### #.# ### #.# #.# +B ##. #.# ##. #.# ##. +C ### #.. #.. #.. ### +D ##. #.# #.# #.# ##. +E ### #.. ### #.. ### +F ### #.. ### #.. #.. +G .## #.. ### #.# .#. +H #.# #.# ### #.# #.# +I ### .#. .#. .#. ### +J ..# ..# ..# #.# .#. +K #.# #.# ##. #.# #.# +L #.. #.. #.. #.. ### +M #.# ### #.# #.# #.# +N ##. #.# #.# #.# #.# +O .#. #.# #.# #.# .#. +P ### #.# ### #.. #.. +Q ### #.# #.# ### ..# +R ### #.# ##. #.# #.# +S ### #.. ### ..# ### +T ### .#. .#. .#. .#. +U #.# #.# #.# #.# ### +V #.# #.# #.# #.# .#. +W #.# #.# #.# ### #.# +X #.# #.# .#. #.# #.# +Y #.# #.# .#. .#. .#. +Z ### ..# .#. #.. ### +`; + export const MANUFACTURERS_RAW = `2A Armament Accuracy International Adams Arms diff --git a/src/domain/magazines/__tests__/dot-matrix-contrast.test.ts b/src/domain/magazines/__tests__/dot-matrix-contrast.test.ts new file mode 100644 index 00000000..e635fb39 --- /dev/null +++ b/src/domain/magazines/__tests__/dot-matrix-contrast.test.ts @@ -0,0 +1,194 @@ +/** + * R15 contrast guard for the dot-matrix tokens (U5, KTD5, KTD6). + * + * Parses `app/globals.css` directly rather than importing a bundled + * stylesheet, because Tailwind v4 silently no-ops an unknown utility class + * (`docs/solutions/best-practices/prefix-collision-safe-token-renaming.md`) + * — a typo'd token name would produce no build error, only a wrong render. + * This test is the only thing that would catch a missing or misnamed + * `--dot-painted` / `--dot-unpainted` declaration in either theme block. + * + * Deliberately does NOT assert a contrast ratio between the two dot tokens + * against each other — KTD5 measured that at 2.67:1 in the dark theme, which + * would fail a same-token-pair 3:1 assertion. R15's "visually distinct from + * one another" clause is carried by the differing dot diameters (KTD6) + * instead, asserted below. + */ + +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { + PAINTED_DOT_DIAMETER_PX, + UNPAINTED_DOT_DIAMETER_PX, +} from "@/app/(app)/magazines/dot-matrix-label"; + +const CSS_PATH = path.join(process.cwd(), "app/globals.css"); + +/** Strips `/* ... *\/` comments so they can't be mistaken for selector text or braces. */ +function stripCssComments(css: string): string { + return css.replace(/\/\*[\s\S]*?\*\//g, ""); +} + +/** + * Finds the `{ ... }` block whose selector list contains `selectorFragment`. + * + * Locates the closing brace by tracking nesting depth rather than grabbing + * the first `}`, so a nested at-rule (`@media`, `@supports`) inside the theme + * block doesn't truncate the match early. + */ +function extractThemeBlock(css: string, selectorFragment: string): string { + const uncommented = stripCssComments(css); + const selectorIndex = uncommented.indexOf(selectorFragment); + if (selectorIndex === -1) { + throw new Error( + `Selector fragment "${selectorFragment}" not found in ${CSS_PATH}.`, + ); + } + const braceStart = uncommented.indexOf("{", selectorIndex); + if (braceStart === -1) { + throw new Error( + `Could not find a { ... } block following "${selectorFragment}".`, + ); + } + let depth = 0; + for (let i = braceStart; i < uncommented.length; i++) { + if (uncommented[i] === "{") depth++; + else if (uncommented[i] === "}") { + depth--; + if (depth === 0) return uncommented.slice(braceStart + 1, i); + } + } + throw new Error( + `No matching closing brace found for "${selectorFragment}" block.`, + ); +} + +/** + * Reads a token declared as a literal 6-digit hex color, e.g. + * `--card: #1a1e24;`. Matches exactly 6 hex digits — not 3 or 8 — because + * `hexToRgb` below only supports the 6-digit form; every token this file + * currently reads (`--foreground`, `--muted-foreground`, `--card`) is written + * that way in `app/globals.css`, and this is the intended contract, not an + * oversight to be worked around by extending `hexToRgb`. A 3- or 8-digit + * value fails here with a clear message naming the token, instead of an + * opaque throw from `hexToRgb`. + */ +function extractHexToken(block: string, tokenName: string): string { + // The trailing boundary matters: without it, `{6}` matches the first six + // digits of an 8-digit `#RRGGBBAA` value and the helper would then check + // contrast for a colour the stylesheet never declares. + const match = block.match( + new RegExp(`--${tokenName}:\\s*(#[0-9a-fA-F]{6})(?![0-9a-fA-F])`), + ); + if (!match?.[1]) { + throw new Error( + `Token "--${tokenName}" not found as a 6-digit hex color (3- and 8-digit hex are unsupported).`, + ); + } + return match[1]; +} + +/** Asserts a token is declared as a `var(--otherToken)` alias and returns the target name. */ +function extractAliasTarget(block: string, tokenName: string): string { + const match = block.match( + new RegExp(`--${tokenName}:\\s*var\\(--([a-zA-Z0-9-]+)\\)`), + ); + if (!match?.[1]) { + throw new Error(`Token "--${tokenName}" is not declared as a var() alias.`); + } + return match[1]; +} + +function hexToRgb(hex: string): [number, number, number] { + const clean = hex.replace("#", ""); + if (clean.length !== 6) { + throw new Error(`Unsupported hex color "${hex}"; expected 6 digits.`); + } + const value = Number.parseInt(clean, 16); + return [(value >> 16) & 255, (value >> 8) & 255, value & 255]; +} + +/** WCAG relative luminance (https://www.w3.org/TR/WCAG21/#dfn-relative-luminance). */ +function relativeLuminance([r, g, b]: [number, number, number]): number { + const [rNorm, gNorm, bNorm] = [r, g, b].map((channel) => { + const srgb = channel / 255; + return srgb <= 0.03928 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4; + }) as [number, number, number]; + return 0.2126 * rNorm + 0.7152 * gNorm + 0.0722 * bNorm; +} + +/** WCAG contrast ratio (https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio). */ +function contrastRatio(hexA: string, hexB: string): number { + const luminanceA = relativeLuminance(hexToRgb(hexA)); + const luminanceB = relativeLuminance(hexToRgb(hexB)); + const lighter = Math.max(luminanceA, luminanceB); + const darker = Math.min(luminanceA, luminanceB); + return (lighter + 0.05) / (darker + 0.05); +} + +const NON_TEXT_CONTRAST_FLOOR = 3; // WCAG 1.4.11 + +const THEMES = [ + { name: "dark", selectorFragment: '[data-theme="dark"]' }, + { name: "light", selectorFragment: '[data-theme="light"]' }, +] as const; + +describe("dot-matrix tokens clear WCAG 1.4.11 (R15, KTD5)", () => { + const css = readFileSync(CSS_PATH, "utf8"); + + for (const theme of THEMES) { + describe(`${theme.name} theme`, () => { + const block = extractThemeBlock(css, theme.selectorFragment); + + test("--dot-painted and --dot-unpainted are both declared", () => { + // A token present in only one theme block is exactly the failure + // Tailwind's silent no-op would not report. + expect(extractAliasTarget(block, "dot-painted")).toBe("foreground"); + expect(extractAliasTarget(block, "dot-unpainted")).toBe( + "muted-foreground", + ); + }); + + test("--dot-painted (aliased to --foreground) clears 3:1 against --card", () => { + const foreground = extractHexToken(block, "foreground"); + const card = extractHexToken(block, "card"); + expect(contrastRatio(foreground, card)).toBeGreaterThanOrEqual( + NON_TEXT_CONTRAST_FLOOR, + ); + }); + + test("--dot-unpainted (aliased to --muted-foreground) clears 3:1 against --card", () => { + const mutedForeground = extractHexToken(block, "muted-foreground"); + const card = extractHexToken(block, "card"); + expect(contrastRatio(mutedForeground, card)).toBeGreaterThanOrEqual( + NON_TEXT_CONTRAST_FLOOR, + ); + }); + }); + } +}); + +describe("extractHexToken only accepts a whole 6-digit hex value", () => { + test("reads a 6-digit token", () => { + expect(extractHexToken("--card: #1a1e24;", "card")).toBe("#1a1e24"); + }); + + test("rejects an 8-digit token instead of matching its first six digits", () => { + // Silently returning "#112233" here would check contrast for a colour the + // stylesheet never declares, and the assertion would still pass. + expect(() => extractHexToken("--card: #11223344;", "card")).toThrow(); + }); + + test("rejects a 3-digit token", () => { + expect(() => extractHexToken("--card: #abc;", "card")).toThrow(); + }); +}); + +describe("dot-matrix geometry carries R15's visual-distinctness clause (KTD6)", () => { + test("painted and unpainted dot diameters differ", () => { + // Deliberately not a token-vs-token contrast assertion (KTD5: 2.67:1 in + // dark theme). The radius difference is what R15 relies on instead. + expect(PAINTED_DOT_DIAMETER_PX).not.toBe(UNPAINTED_DOT_DIAMETER_PX); + }); +}); diff --git a/src/domain/magazines/__tests__/dot-matrix.test.ts b/src/domain/magazines/__tests__/dot-matrix.test.ts new file mode 100644 index 00000000..98ccc598 --- /dev/null +++ b/src/domain/magazines/__tests__/dot-matrix.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, test } from "bun:test"; +import { resolveDotMatrix } from "../dot-matrix"; +import { type GlyphTable, parseGlyphTable } from "../glyphs"; + +// U3 — label-to-matrix resolution (R6-R10). Pure, no DB, no React. +// +// The glyph table is a resolver parameter (KTD2), so every case below runs +// against a synthetic fixture rather than the (currently empty) shipped +// font. Shape of each glyph is irrelevant to this unit; only presence or +// absence of a character in the table matters. +const ALL_CHARACTERS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-".split(""); +const SAMPLE_ROW = "###"; +const TEST_GLYPHS_RAW = ALL_CHARACTERS.map( + (ch) => + `${ch} ${SAMPLE_ROW} ${SAMPLE_ROW} ${SAMPLE_ROW} ${SAMPLE_ROW} ${SAMPLE_ROW}`, +).join("\n"); +const TEST_GLYPHS: GlyphTable = parseGlyphTable(TEST_GLYPHS_RAW); +const EMPTY_GLYPHS: GlyphTable = parseGlyphTable(""); + +// Real brandModel strings resolved through U2's actual MODEL_CELL_COUNTS — +// dot-matrix.ts calls resolveCellCount internally rather than taking a +// pre-resolved count (only the glyph table is injected, per KTD2). +const FOUR_CELL_MODEL = "Magpul PMAG 20 LR/SR GEN M3"; +const TWO_CELL_MODEL = "Magpul PMAG 17 GL9"; +const UNMATCHED_MODEL = "Some Unknown Brand 30rd"; + +describe("resolveDotMatrix", () => { + test("covers AE6: owner mode off returns hidden, whatever the label", () => { + expect( + resolveDotMatrix({ + label: "US04", + brandModel: FOUR_CELL_MODEL, + ownerMagpulMode: false, + glyphs: TEST_GLYPHS, + }), + ).toEqual({ kind: "hidden" }); + }); + + test("covers AE7: empty label returns hidden", () => { + expect( + resolveDotMatrix({ + label: "", + brandModel: FOUR_CELL_MODEL, + ownerMagpulMode: true, + glyphs: TEST_GLYPHS, + }), + ).toEqual({ kind: "hidden" }); + }); + + test("a label of only whitespace returns hidden", () => { + expect( + resolveDotMatrix({ + label: " ", + brandModel: FOUR_CELL_MODEL, + ownerMagpulMode: true, + glyphs: TEST_GLYPHS, + }), + ).toEqual({ kind: "hidden" }); + }); + + test("covers KTD3: a non-empty label with an empty glyph table returns hidden, not unrepresentable", () => { + expect( + resolveDotMatrix({ + label: "US04", + brandModel: FOUR_CELL_MODEL, + ownerMagpulMode: true, + glyphs: EMPTY_GLYPHS, + }), + ).toEqual({ kind: "hidden" }); + }); + + test("covers AE1: US04 on a 4-cell model renders U, S, 0, 4 with cellCountVerified true", () => { + const result = resolveDotMatrix({ + label: "US04", + brandModel: FOUR_CELL_MODEL, + ownerMagpulMode: true, + glyphs: TEST_GLYPHS, + }); + expect(result.kind).toBe("matrix"); + if (result.kind !== "matrix") throw new Error("expected matrix"); + expect(result.characters).toEqual(["U", "S", "0", "4"]); + expect(result.cellCount).toBe(4); + expect(result.cellCountVerified).toBe(true); + expect(result.cells.length).toBe(4); + }); + + test("covers AE2: US04 on a 2-cell GL9 renders only 0, 4 — the prefix is dropped", () => { + const result = resolveDotMatrix({ + label: "US04", + brandModel: TWO_CELL_MODEL, + ownerMagpulMode: true, + glyphs: TEST_GLYPHS, + }); + expect(result.kind).toBe("matrix"); + if (result.kind !== "matrix") throw new Error("expected matrix"); + expect(result.characters).toEqual(["0", "4"]); + expect(result.cellCount).toBe(2); + expect(result.cellCountVerified).toBe(true); + }); + + test("covers AE3: AR12 on an unmatched model renders all four characters with cellCountVerified false", () => { + const result = resolveDotMatrix({ + label: "AR12", + brandModel: UNMATCHED_MODEL, + ownerMagpulMode: true, + glyphs: TEST_GLYPHS, + }); + expect(result.kind).toBe("matrix"); + if (result.kind !== "matrix") throw new Error("expected matrix"); + expect(result.characters).toEqual(["A", "R", "1", "2"]); + expect(result.cellCount).toBe(4); + expect(result.cellCountVerified).toBe(false); + }); + + test("covers AE4: AR-X on a 2-cell GL9 is unrepresentable — within the font but overflows with no trailing digit run", () => { + expect( + resolveDotMatrix({ + label: "AR-X", + brandModel: TWO_CELL_MODEL, + ownerMagpulMode: true, + glyphs: TEST_GLYPHS, + }), + ).toEqual({ + kind: "unrepresentable", + cellCount: 2, + cellCountVerified: true, + }); + }); + + test("covers AE5: A.1 is unrepresentable because '.' is absent from the font, even though the length would fit", () => { + expect( + resolveDotMatrix({ + label: "A.1", + brandModel: FOUR_CELL_MODEL, + ownerMagpulMode: true, + glyphs: TEST_GLYPHS, + }), + ).toEqual({ + kind: "unrepresentable", + cellCount: 4, + cellCountVerified: true, + }); + }); + + test("covers AE9: 1234 on a 2-cell GL9 is unrepresentable — the trailing digit run is the whole label and still overflows", () => { + expect( + resolveDotMatrix({ + label: "1234", + brandModel: TWO_CELL_MODEL, + ownerMagpulMode: true, + glyphs: TEST_GLYPHS, + }), + ).toEqual({ + kind: "unrepresentable", + cellCount: 2, + cellCountVerified: true, + }); + }); + + test("combined, no AE: an unmatched model whose label is unrepresentable returns cellCountVerified false", () => { + // "A.1" is unrepresentable on any cell count because "." is absent from + // the font (unlike AE4's "AR-X", which would fit an unmatched model's + // 4-cell fallback and so is not a useful combined-case fixture here). + expect( + resolveDotMatrix({ + label: "A.1", + brandModel: UNMATCHED_MODEL, + ownerMagpulMode: true, + glyphs: TEST_GLYPHS, + }), + ).toEqual({ + kind: "unrepresentable", + cellCount: 4, + cellCountVerified: false, + }); + }); + + test("boundary R7/R8: a label exactly equal to the cell count renders every character", () => { + const result = resolveDotMatrix({ + label: "AB12", + brandModel: FOUR_CELL_MODEL, + ownerMagpulMode: true, + glyphs: TEST_GLYPHS, + }); + expect(result.kind).toBe("matrix"); + if (result.kind !== "matrix") throw new Error("expected matrix"); + expect(result.characters).toEqual(["A", "B", "1", "2"]); + }); + + test("boundary R8/R9: a trailing digit run exactly equal to the cell count renders that run", () => { + const result = resolveDotMatrix({ + label: "AB12", + brandModel: TWO_CELL_MODEL, + ownerMagpulMode: true, + glyphs: TEST_GLYPHS, + }); + expect(result.kind).toBe("matrix"); + if (result.kind !== "matrix") throw new Error("expected matrix"); + expect(result.characters).toEqual(["1", "2"]); + }); + + test("lowercase characters in a stored label are uppercased via normalizeMagpulLabel before resolution", () => { + const result = resolveDotMatrix({ + label: "us04", + brandModel: FOUR_CELL_MODEL, + ownerMagpulMode: true, + glyphs: TEST_GLYPHS, + }); + expect(result.kind).toBe("matrix"); + if (result.kind !== "matrix") throw new Error("expected matrix"); + expect(result.characters).toEqual(["U", "S", "0", "4"]); + }); +}); diff --git a/src/domain/magazines/__tests__/floorplate.test.ts b/src/domain/magazines/__tests__/floorplate.test.ts new file mode 100644 index 00000000..ffa15a72 --- /dev/null +++ b/src/domain/magazines/__tests__/floorplate.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from "bun:test"; +import { + FALLBACK_CELL_COUNT, + MODEL_CELL_COUNTS, + normalizeModel, + resolveCellCount, +} from "../floorplate"; + +// U2 — floorplate cell-count lookup (R3, R4, R5). Pure, no DB, no React. +describe("resolveCellCount", () => { + test('"Magpul PMAG 17 GL9" resolves to 2 cells, matched', () => { + expect(resolveCellCount("Magpul PMAG 17 GL9")).toEqual({ + cells: 2, + matched: true, + }); + }); + + test('"magpul pmag 15 gl9" resolves to 2 cells, matched — case-insensitive', () => { + expect(resolveCellCount("magpul pmag 15 gl9")).toEqual({ + cells: 2, + matched: true, + }); + }); + + test('"Magpul GL9" resolves to 2 cells, matched — the PMAG-less shorthand regression guard', () => { + expect(resolveCellCount("Magpul GL9")).toEqual({ + cells: 2, + matched: true, + }); + }); + + test('"Magpul PMAG 20 LR/SR GEN M3" resolves to 4 cells, matched — a 4-cell model is a match, not a fallback', () => { + expect(resolveCellCount("Magpul PMAG 20 LR/SR GEN M3")).toEqual({ + cells: 4, + matched: true, + }); + }); + + test('"Magpul PMAG 25 7.62x51" has no LR/SR marker in its model string, so it falls through to the 4-cell unmatched fallback rather than a caliber-only match', () => { + expect(resolveCellCount("Magpul PMAG 25 7.62x51")).toEqual({ + cells: FALLBACK_CELL_COUNT, + matched: false, + }); + }); + + test('"PTR-91 7.62X51" (a non-Magpul magazine whose model string happens to contain a caliber) resolves to the 4-cell unmatched fallback, never a confirmed match', () => { + expect(resolveCellCount("PTR-91 7.62X51")).toEqual({ + cells: FALLBACK_CELL_COUNT, + matched: false, + }); + }); + + test('"DPMS SR-25 7.62x51" likewise does not match — a caliber token must never cause a cross-brand false match', () => { + expect(resolveCellCount("DPMS SR-25 7.62x51")).toEqual({ + cells: FALLBACK_CELL_COUNT, + matched: false, + }); + }); + + test('"Some Unknown Brand 30rd" resolves to 4 cells, unmatched', () => { + expect(resolveCellCount("Some Unknown Brand 30rd")).toEqual({ + cells: FALLBACK_CELL_COUNT, + matched: false, + }); + }); + + test('"" and " " resolve to 4 cells, unmatched, without throwing', () => { + expect(resolveCellCount("")).toEqual({ + cells: FALLBACK_CELL_COUNT, + matched: false, + }); + expect(resolveCellCount(" ")).toEqual({ + cells: FALLBACK_CELL_COUNT, + matched: false, + }); + }); + + test('"Magpul P-MAG 17 GL9" still matches the GL9 entry — punctuation and extra whitespace are stripped', () => { + expect(resolveCellCount("Magpul P-MAG 17 GL9")).toEqual({ + cells: 2, + matched: true, + }); + }); + + test("every entry in MODEL_CELL_COUNTS has at least one token and a positive cell count", () => { + expect(MODEL_CELL_COUNTS.length).toBeGreaterThan(0); + for (const entry of MODEL_CELL_COUNTS) { + expect(entry.tokens.length).toBeGreaterThan(0); + for (const token of entry.tokens) { + expect(token.length).toBeGreaterThan(0); + } + expect(entry.cells).toBeGreaterThan(0); + } + }); +}); + +describe("normalizeModel", () => { + test("uppercases and strips every character outside A-Z0-9", () => { + expect(normalizeModel("Magpul PMAG 17 GL9")).toBe("MAGPULPMAG17GL9"); + expect(normalizeModel("Magpul PMAG 25 7.62x51")).toBe("MAGPULPMAG25762X51"); + }); +}); diff --git a/src/domain/magazines/__tests__/glyphs.test.ts b/src/domain/magazines/__tests__/glyphs.test.ts new file mode 100644 index 00000000..ccc2b076 --- /dev/null +++ b/src/domain/magazines/__tests__/glyphs.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { MAGPUL_GLYPHS_RAW } from "@/src/data/raw"; +import { type GlyphTable, MAGPUL_GLYPHS, parseGlyphTable } from "../glyphs"; + +const GLYPHS_TXT_PATH = path.join(process.cwd(), "src/data/magpul-glyphs.txt"); + +// U1 — glyph table fixture and loader (R1, R2). Pure, no DB, no React. +describe("parseGlyphTable", () => { + test("parses a well-formed three-glyph table into a lookup keyed by character", () => { + const raw = [ + "0 ### #.# #.# #.# ###", + "1 .#. .#. .#. .#. .#.", + "A ### #.# ### #.# #.#", + ].join("\n"); + + const table = parseGlyphTable(raw); + + expect(table.size).toBe(3); + expect(table.get("0")).toEqual([ + [true, true, true], + [true, false, true], + [true, false, true], + [true, false, true], + [true, true, true], + ]); + expect(table.get("1")).toEqual([ + [false, true, false], + [false, true, false], + [false, true, false], + [false, true, false], + [false, true, false], + ]); + expect(table.get("A")).toEqual([ + [true, true, true], + [true, false, true], + [true, true, true], + [true, false, true], + [true, false, true], + ]); + }); + + test("throws when a glyph row has four columns instead of three", () => { + const raw = "0 ###. #.# #.# #.# ###"; + expect(() => parseGlyphTable(raw)).toThrow(); + }); + + test("throws when a glyph declares four rows instead of five", () => { + const raw = "0 ### #.# #.# ###"; + expect(() => parseGlyphTable(raw)).toThrow(); + }); + + test("throws when a row contains a character other than # or .", () => { + const raw = "0 ### #x# #.# #.# ###"; + expect(() => parseGlyphTable(raw)).toThrow(); + }); + + test("throws when the same glyph character is declared twice", () => { + const raw = ["0 ### #.# #.# #.# ###", "0 ### #.# #.# #.# ###"].join("\n"); + expect(() => parseGlyphTable(raw)).toThrow(); + }); + + test("throws when the leading field is not a single character", () => { + const raw = "AB ### ### ### ### ###"; + expect(() => parseGlyphTable(raw)).toThrow(); + }); + + test("throws when the glyph key is a lowercase letter", () => { + const raw = "a ### ### ### ### ###"; + expect(() => parseGlyphTable(raw)).toThrow(); + }); + + test("throws when the glyph key is punctuation outside the font", () => { + const raw = ". ### ### ### ### ###"; + expect(() => parseGlyphTable(raw)).toThrow(); + }); + + test("parses a digit glyph key", () => { + const raw = "7 ### #.# #.# #.# ###"; + const table = parseGlyphTable(raw); + expect(table.has("7")).toBe(true); + }); + + test("parses an uppercase letter glyph key", () => { + const raw = "Z ### #.# #.# #.# ###"; + const table = parseGlyphTable(raw); + expect(table.has("Z")).toBe(true); + }); + + test("parses the hyphen glyph key", () => { + const raw = "- ### #.# #.# #.# ###"; + const table = parseGlyphTable(raw); + expect(table.has("-")).toBe(true); + }); + + test("parses correctly when fields are separated by multiple spaces", () => { + const raw = "0 ### #.# #.# #.# ###"; + const table = parseGlyphTable(raw); + expect(table.get("0")).toEqual([ + [true, true, true], + [true, false, true], + [true, false, true], + [true, false, true], + [true, true, true], + ]); + }); + + test("parses correctly with CRLF line endings", () => { + const raw = ["0 ### #.# #.# #.# ###", "1 .#. .#. .#. .#. .#."].join("\r\n"); + + const table = parseGlyphTable(raw); + + expect(table.size).toBe(2); + expect(table.get("0")).toEqual([ + [true, true, true], + [true, false, true], + [true, false, true], + [true, false, true], + [true, true, true], + ]); + expect(table.get("1")).toEqual([ + [false, true, false], + [false, true, false], + [false, true, false], + [false, true, false], + [false, true, false], + ]); + }); + + test("returns an empty table for input that is entirely comments and blank lines", () => { + const raw = ["# a comment", "", " ", "# another comment"].join("\n"); + const table = parseGlyphTable(raw); + expect(table.size).toBe(0); + }); + + test("the shipped src/data/magpul-glyphs.txt matches the embedded MAGPUL_GLYPHS_RAW and parses without throwing", () => { + // `MAGPUL_GLYPHS_RAW` in src/data/raw.ts is a hand-maintained copy of + // src/data/magpul-glyphs.txt (raw.ts exists so the fixture loads without + // filesystem access in the Next bundle, standalone output, and Docker — + // see its header comment). Nothing regenerates raw.ts from the .txt file, + // so editing one and forgetting the other would silently ship the old + // embedded string while the .txt file on disk (and in review) shows the + // edit. Reading the .txt file directly is what catches that drift; parsing + // only `MAGPUL_GLYPHS_RAW` (as the previous version of this test did) + // would pass even when the two have diverged. + const onDisk = readFileSync(GLYPHS_TXT_PATH, "utf8"); + + expect(onDisk).toBe(MAGPUL_GLYPHS_RAW); + expect(() => parseGlyphTable(onDisk)).not.toThrow(); + }); +}); + +describe("MAGPUL_GLYPHS", () => { + test("covers every character in 0-9 and A-Z, and carries no hyphen (the transcribed Magpul sheet has 36 glyphs, no punctuation)", () => { + const table: GlyphTable = MAGPUL_GLYPHS; + + const expectedCharacters = [ + ..."0123456789", + ..."ABCDEFGHIJKLMNOPQRSTUVWXYZ", + ]; + + expect(table.size).toBe(36); + for (const character of expectedCharacters) { + expect(table.has(character)).toBe(true); + } + expect(table.has("-")).toBe(false); + }); + + test("spot-checks '0' and '1' against known glyph patterns, to catch a bad regeneration", () => { + const table: GlyphTable = MAGPUL_GLYPHS; + + expect(table.get("0")).toEqual([ + [true, true, true], + [true, false, true], + [true, false, true], + [true, false, true], + [true, true, true], + ]); + expect(table.get("1")).toEqual([ + [true, true, false], + [false, true, false], + [false, true, false], + [false, true, false], + [true, true, true], + ]); + }); +}); diff --git a/src/domain/magazines/__tests__/service.test.ts b/src/domain/magazines/__tests__/service.test.ts index 8aa59b0c..9fcaf3ff 100644 --- a/src/domain/magazines/__tests__/service.test.ts +++ b/src/domain/magazines/__tests__/service.test.ts @@ -346,6 +346,71 @@ describe("createMagazine — prefix recording (#22)", () => { }); }); +// U4: getMagazine resolves the magazine OWNER's magpulMode, not the viewer's. +describe("getMagazine — owner-scoped magpulMode (U4/R6/KTD7)", () => { + test("owner reading their own magazine with mode on gets ownerMagpulMode: true", async () => { + const owner = await createUser("u4-owner-on"); + await db.update(user).set({ magpulMode: true }).where(eq(user.id, owner)); + const mag = await makeMagazine(owner); + + const result = await getMagazine(owner, mag.id); + + expect(result.ownerMagpulMode).toBe(true); + await deleteUsers(owner); + }); + + test("a view-grantee with mode OFF reading an owner's magazine with mode ON gets ownerMagpulMode: true (R6)", async () => { + const owner = await createUser("u4-owner-on2"); + const grantee = await createUser("u4-grantee-off"); + await db.update(user).set({ magpulMode: true }).where(eq(user.id, owner)); + // grantee's own magpulMode defaults to off — never set. + const mag = await makeMagazine(owner); + await createGrant(db, { + actorId: owner, + granteeId: grantee, + parentType: "magazine", + parentId: mag.id, + permission: "view", + }); + + const result = await getMagazine(grantee, mag.id); + + expect(result.ownerMagpulMode).toBe(true); + await deleteUsers(owner, grantee); + }); + + test("inverse: owner's flag off, grantee's flag on → ownerMagpulMode: false", async () => { + const owner = await createUser("u4-owner-off"); + const grantee = await createUser("u4-grantee-on"); + // owner's magpulMode defaults to off — never set. + await db.update(user).set({ magpulMode: true }).where(eq(user.id, grantee)); + const mag = await makeMagazine(owner); + await createGrant(db, { + actorId: owner, + granteeId: grantee, + parentType: "magazine", + parentId: mag.id, + permission: "view", + }); + + const result = await getMagazine(grantee, mag.id); + + expect(result.ownerMagpulMode).toBe(false); + await deleteUsers(owner, grantee); + }); + + test("still throws NotFoundError for a magazine the actor cannot see — no visibility hole", async () => { + const owner = await createUser("u4-owner-hidden"); + const stranger = await createUser("u4-stranger"); + const mag = await makeMagazine(owner); + + await expect(getMagazine(stranger, mag.id)).rejects.toBeInstanceOf( + NotFoundError, + ); + await deleteUsers(owner, stranger); + }); +}); + // Action-log wiring at the create/delete seams (U6, R17, R18, KTD-5). describe("magazines service — action-log wiring (U6)", () => { test("creating a magazine inside runWithContext emits exactly one action line", async () => { diff --git a/src/domain/magazines/dot-matrix.ts b/src/domain/magazines/dot-matrix.ts new file mode 100644 index 00000000..36c921b5 --- /dev/null +++ b/src/domain/magazines/dot-matrix.ts @@ -0,0 +1,122 @@ +/** + * Label-to-matrix resolution (U3, KTD2, KTD3; R6-R10). Pure — no DB, no + * React. + * + * `resolveDotMatrix` turns a magazine's label, model, and owner Magpul mode + * into one of three structured outcomes, so the presentation layer branches + * once over a discriminated union rather than a chain of nullable fields. + * Only the glyph table is a caller-supplied parameter (KTD2) — production + * wiring hands it `MAGPUL_GLYPHS`, and every test in + * `__tests__/dot-matrix.test.ts` hands it a synthetic fixture, which is what + * makes R7-R10 fully testable today even though the shipped font is empty. + * Cell-count resolution calls U2's `resolveCellCount` directly; the model + * list itself is a code-only, non-owner-editable lookup (Product Contract, + * session-settled), not something a caller substitutes. + */ + +import { normalizeMagpulLabel } from "./constants"; +import { resolveCellCount } from "./floorplate"; +import type { GlyphCell, GlyphTable } from "./glyphs"; + +/** Every rule in R6-R10 is a named case, not a nullable field. */ +export type DotMatrixResult = + | { kind: "hidden" } + | { + kind: "matrix"; + /** The characters actually drawn, in order (R7 the whole label, R8 the trailing digit run). */ + characters: readonly string[]; + /** The glyph cell for each drawn character, same order as `characters`. */ + cells: readonly GlyphCell[]; + /** The floorplate's per-face cell count (R3, R5). */ + cellCount: number; + /** False when `cellCount` came from R4's unrecognized-model fallback. */ + cellCountVerified: boolean; + } + | { + kind: "unrepresentable"; + cellCount: number; + cellCountVerified: boolean; + }; + +export interface ResolveDotMatrixInput { + label: string; + brandModel: string; + ownerMagpulMode: boolean; + glyphs: GlyphTable; +} + +/** The maximal run of `0-9` at the end of `value`, or `""` when there is none. */ +function trailingDigitRun(value: string): string { + const match = value.match(/[0-9]+$/); + return match ? match[0] : ""; +} + +/** + * Resolves in the order the Product Contract's flowchart specifies: owner + * mode off, then an empty label, then an empty glyph table (KTD3) all yield + * `hidden`; then cell count is resolved from `brandModel`; then font + * coverage is checked against the whole normalized label (R9's first + * clause, before any truncation); then length against the cell count; then + * the trailing-digit fallback. + */ +export function resolveDotMatrix( + input: ResolveDotMatrixInput, +): DotMatrixResult { + const { label, brandModel, ownerMagpulMode, glyphs } = input; + + if (!ownerMagpulMode) return { kind: "hidden" }; + + const normalizedLabel = normalizeMagpulLabel(label); + if (normalizedLabel === "") return { kind: "hidden" }; + + // KTD3: an empty glyph table suppresses the matrix entirely — this is the + // mechanism by which the feature ships dark, not an R9 "does not fit". + if (glyphs.size === 0) return { kind: "hidden" }; + + const { cells: cellCount, matched } = resolveCellCount(brandModel); + const cellCountVerified = matched; + + const characters = [...normalizedLabel]; + const hasFullFontCoverage = characters.every((character) => + glyphs.has(character), + ); + if (!hasFullFontCoverage) { + return { kind: "unrepresentable", cellCount, cellCountVerified }; + } + + if (characters.length <= cellCount) { + return buildMatrixResult(characters, glyphs, cellCount, cellCountVerified); + } + + const trailingDigits = trailingDigitRun(normalizedLabel); + if (trailingDigits.length > 0 && trailingDigits.length <= cellCount) { + return buildMatrixResult( + [...trailingDigits], + glyphs, + cellCount, + cellCountVerified, + ); + } + + return { kind: "unrepresentable", cellCount, cellCountVerified }; +} + +function buildMatrixResult( + characters: readonly string[], + glyphs: GlyphTable, + cellCount: number, + cellCountVerified: boolean, +): DotMatrixResult { + const cells = characters.map((character) => { + const cell = glyphs.get(character); + if (!cell) { + // Coverage was already checked against the full label before this + // point, so a miss here would mean the two checks disagreed. + throw new Error( + `Internal error: no glyph cell for character "${character}" after coverage check passed.`, + ); + } + return cell; + }); + return { kind: "matrix", characters, cells, cellCount, cellCountVerified }; +} diff --git a/src/domain/magazines/floorplate.ts b/src/domain/magazines/floorplate.ts new file mode 100644 index 00000000..2f928f60 --- /dev/null +++ b/src/domain/magazines/floorplate.ts @@ -0,0 +1,87 @@ +/** + * Floorplate cell-count lookup (U2, KTD4; R3, R4, R5). Pure — no DB, no + * React. + * + * Resolves a magazine's free-text `brandModel` to how many PMAG Gen M3 dot + * cells its floorplate holds, per one face (the Product Contract's + * session-settled "a floorplate's cell count means one face" decision). The + * list is a built-in, code-only lookup — not owner-editable (also + * session-settled) — and evolves through code changes the same way + * `src/domain/firearms/constants.ts` evolves its taxonomy lists, not through + * a UI. + * + * A miss is not harmless: it renders under R4's 4-cell fallback, which is + * confidently wrong guidance for a magazine that actually holds fewer cells. + * That is why every entry — including 4-cell models — is listed explicitly + * (KTD4): a match means a *confirmed* count, and a wrong count on a matched + * entry is worse than an unrecognized model, because the unrecognized case + * at least carries R4's "unverified" caveat. + */ + +/** Fallback cell count for a `brandModel` matching no known entry (R4). */ +export const FALLBACK_CELL_COUNT = 4; + +interface ModelCellCountEntry { + /** Human-readable label for the entry; not matched against, documentation only. */ + readonly name: string; + /** Every token must appear (as a substring) in the normalized model for a match. */ + readonly tokens: readonly string[]; + /** Per-face dot cell count for this family. */ + readonly cells: number; +} + +/** + * Ordered most-specific-first (KTD4): the first entry whose tokens all match + * wins. Keyed on the distinctive family marker alone — never a brand token + * like `PMAG` — because `MAGPUL` does not contain the substring `PMAG`, and + * requiring it would reject the natural shorthand `Magpul GL9`. + * + * Expected to grow as counts are sourced and cross-checked (see the plan's + * Dependencies section). A wrong count here is worse than a missing entry: a + * matched entry carries no "unverified" caveat, so an error is silently + * presented as confirmed. + */ +export const MODEL_CELL_COUNTS: readonly ModelCellCountEntry[] = [ + { name: "GL9 family", tokens: ["GL9"], cells: 2 }, + { + name: "PMAG 20 LR/SR GEN M3 (Magpul naming)", + tokens: ["LRSR"], + cells: 4, + }, + // Deliberately no caliber-only entry (e.g. a bare "762X51" token). Matching + // is substring containment over the whole free-text `brandModel`, so a + // caliber token collides across brands: a non-Magpul magazine like + // "PTR-91 7.62X51" or "DPMS SR-25 7.62x51" would satisfy it and resolve a + // *confirmed* PMAG cell count for a magazine with no PMAG dot-matrix + // floorplate at all. Tokens here must be distinctive Magpul model + // designations (like `GL9`, `LRSR`) that do not appear in other brands' + // model strings — never a caliber, which is brand-agnostic by definition. +]; + +/** Uppercases and strips every character outside `A-Z0-9` into one dense token. */ +export function normalizeModel(brandModel: string): string { + return brandModel.toUpperCase().replace(/[^A-Z0-9]/g, ""); +} + +export interface CellCountResolution { + cells: number; + matched: boolean; +} + +/** + * Resolves `brandModel` to a per-face cell count. Matches by required- + * substring containment against the normalized model, first match wins + * (KTD4). An empty, whitespace-only, or unrecognized `brandModel` returns + * the fallback with `matched: false` — never throws. + */ +export function resolveCellCount(brandModel: string): CellCountResolution { + const normalized = normalizeModel(brandModel); + + for (const entry of MODEL_CELL_COUNTS) { + if (entry.tokens.every((token) => normalized.includes(token))) { + return { cells: entry.cells, matched: true }; + } + } + + return { cells: FALLBACK_CELL_COUNT, matched: false }; +} diff --git a/src/domain/magazines/glyphs.ts b/src/domain/magazines/glyphs.ts new file mode 100644 index 00000000..1bb11c0f --- /dev/null +++ b/src/domain/magazines/glyphs.ts @@ -0,0 +1,119 @@ +/** + * Magpul PMAG Gen M3 dot-matrix glyph font (U1; R1, R2). Pure — no DB, no + * React. + * + * Parses the checked-in glyph fixture (`src/data/magpul-glyphs.txt`, embedded + * as `MAGPUL_GLYPHS_RAW` in `src/data/raw.ts`) into a lookup from character to + * a fixed 3-column x 5-row dot cell. `MAGPUL_GLYPHS` is exported as a plain + * parsed constant rather than a cache-returning function: `resolveDotMatrix` + * (U3, KTD2) takes the glyph table as a parameter, so production wiring and + * tests both hand it a `GlyphTable` value directly instead of importing a + * shared cache. Mirrors `src/domain/reference/reference.ts`'s note that a + * shallow `Object.freeze` would not protect a glyph cell's nested row arrays + * anyway, so none is applied here — readonly types are the contract. + * + * The fixture ships with zero glyph rows until Magpul's diagram is + * transcribed (KTD3) — an empty `GlyphTable` is a valid, expected state, not + * an error, and is exactly what makes the feature ship dark. + */ + +import { MAGPUL_GLYPHS_RAW } from "@/src/data/raw"; +import { + MAGPUL_LABEL_ALLOWED_DESCRIPTION, + MAGPUL_LABEL_ALLOWED_RE, +} from "@/src/domain/magazines/constants"; + +/** One row of a glyph cell: `true` = painted dot, `false` = unpainted (R11). */ +export type GlyphRow = readonly boolean[]; + +/** A glyph's fixed dot cell: 3 columns x 5 rows (R2). */ +export type GlyphCell = readonly GlyphRow[]; + +/** Character -> glyph cell lookup, keyed on the literal transcribed character. */ +export type GlyphTable = ReadonlyMap; + +const ROWS_PER_GLYPH = 5; +const COLS_PER_GLYPH = 3; +const PAINTED_MARK = "#"; +const UNPAINTED_MARK = "."; + +/** + * Parses one glyph row (`rowField`, e.g. `"#.#"`) into a `GlyphRow`. Throws + * when the row is not exactly `COLS_PER_GLYPH` characters wide, or contains + * any mark other than `#` or `.` — a malformed transcription fails loudly at + * parse time rather than silently rendering a wrong pattern later. + */ +function parseGlyphRow(character: string, rowField: string): GlyphRow { + if (rowField.length !== COLS_PER_GLYPH) { + throw new Error( + `Glyph "${character}" row "${rowField}" has ${rowField.length} columns; expected ${COLS_PER_GLYPH}.`, + ); + } + const dots: boolean[] = []; + for (const mark of rowField) { + if (mark === PAINTED_MARK) { + dots.push(true); + } else if (mark === UNPAINTED_MARK) { + dots.push(false); + } else { + throw new Error( + `Glyph "${character}" row "${rowField}" contains invalid mark "${mark}" (expected "${PAINTED_MARK}" or "${UNPAINTED_MARK}").`, + ); + } + } + return dots; +} + +/** + * Parses the glyph fixture format: one glyph per line, ` + * `, each row exactly `COLS_PER_GLYPH` characters + * of `#`/`.`. Blank lines and lines beginning with `#` in column zero are + * comments — unambiguous, because a glyph row's first field is always a + * single character followed by a space, and `#` itself is outside R1's font. + * + * Throws on any malformed row: wrong row count, wrong row width, a mark + * outside `#.`, a glyph key outside the Magpul label character set (R1: + * `A-Z`, `0-9`, hyphen — shared with `MAGPUL_LABEL_ALLOWED_RE`), or a + * duplicate glyph character declared twice. Rejecting an out-of-set key here + * matters beyond transcription hygiene: R9 treats a label containing a + * character absent from the font as unrepresentable, and a stray accepted + * glyph (e.g. `.` or a lowercase letter) would let a nonconforming label + * render instead. Empty input (or input that is entirely comments/blank + * lines) yields an empty table — a valid state, not an error (KTD3). + */ +export function parseGlyphTable(raw: string): GlyphTable { + const table = new Map(); + + for (const line of raw.split("\n")) { + if (line.trim() === "" || line.startsWith("#")) continue; + + const [character, ...rowFields] = line.trim().split(/\s+/); + + if (character?.length !== 1) { + throw new Error(`Malformed glyph line (missing character): "${line}"`); + } + if (!MAGPUL_LABEL_ALLOWED_RE.test(character)) { + throw new Error( + `Glyph key "${character}" is outside the supported character set (${MAGPUL_LABEL_ALLOWED_DESCRIPTION}).`, + ); + } + if (table.has(character)) { + throw new Error(`Duplicate glyph declaration for "${character}".`); + } + if (rowFields.length !== ROWS_PER_GLYPH) { + throw new Error( + `Glyph "${character}" declares ${rowFields.length} rows; expected ${ROWS_PER_GLYPH}.`, + ); + } + + const cell: GlyphRow[] = rowFields.map((rowField) => + parseGlyphRow(character, rowField), + ); + table.set(character, cell); + } + + return table; +} + +/** Parsed once at module load from the checked-in fixture (KTD2, KTD3). */ +export const MAGPUL_GLYPHS: GlyphTable = parseGlyphTable(MAGPUL_GLYPHS_RAW); diff --git a/src/domain/magazines/service.ts b/src/domain/magazines/service.ts index 1b8d0cbc..3e692b3b 100644 --- a/src/domain/magazines/service.ts +++ b/src/domain/magazines/service.ts @@ -86,6 +86,24 @@ function scalarFields( }; } +/** + * Resolves an owner's magpulMode by id. Fails loudly rather than treating a + * missing owner row as mode-off — a magazine always carries a valid owner FK, + * so a miss means corrupt state, not a disabled setting. + */ +async function resolveOwnerMagpulMode( + database: DbOrTx, + ownerId: string, +): Promise { + const [ownerRow] = await database + .select({ magpulMode: user.magpulMode }) + .from(user) + .where(eq(user.id, ownerId)) + .limit(1); + if (!ownerRow) throw new NotFoundError(); + return ownerRow.magpulMode ?? false; +} + /** Attach viewer-relative compatibility (ordinal order, unseen firearms dropped). */ async function attachCompatibility( database: DbOrTx, @@ -112,16 +130,7 @@ export async function createMagazine( const row = await db.transaction(async (tx) => { const ownerId = await resolveCreateOwner(tx, actorId, input.ownerId); - const [ownerRow] = await tx - .select({ magpulMode: user.magpulMode }) - .from(user) - .where(eq(user.id, ownerId)) - .limit(1); - // The owner was just resolved/authorized; a missing row means corrupt - // state, not "mode off" — fail loudly rather than silently skipping the - // constraint. - if (!ownerRow) throw new NotFoundError(); - const ownerMagpulMode = ownerRow.magpulMode ?? false; + const ownerMagpulMode = await resolveOwnerMagpulMode(tx, ownerId); const codes = validateMagazine(input, 1, { ownerMagpulMode, @@ -183,15 +192,7 @@ export async function updateMagazine( .limit(1); if (!existing) throw new NotFoundError(); - const [ownerRow] = await tx - .select({ magpulMode: user.magpulMode }) - .from(user) - .where(eq(user.id, existing.ownerId)) - .limit(1); - // A magazine always has a valid owner (FK); a missing row is corrupt state, - // not "mode off" — fail loudly rather than silently skipping the check. - if (!ownerRow) throw new NotFoundError(); - const ownerMagpulMode = ownerRow.magpulMode ?? false; + const ownerMagpulMode = await resolveOwnerMagpulMode(tx, existing.ownerId); const codes = validateMagazine(input, 1, { ownerMagpulMode, @@ -263,7 +264,13 @@ export async function deleteMagazine( export async function getMagazine( actorId: string, id: string, -): Promise<{ magazine: MagazineWithCompatibility; permission: Permission }> { +): Promise<{ + magazine: MagazineWithCompatibility; + permission: Permission; + ownerMagpulMode: boolean; +}> { + // Authorization gate stays first: the owner-mode lookup below must never run + // for a magazine the actor can't see, or it becomes an oracle for existence. const permission = await resolvePermission(db, actorId, "magazine", id); if (permission === null) throw new NotFoundError(); const [row] = await db @@ -272,10 +279,15 @@ export async function getMagazine( .where(eq(magazine.id, id)) .limit(1); if (!row) throw new NotFoundError(); - const [withCompat] = await attachCompatibility(db, actorId, [row]); + // Independent once the authorization/existence gates above have passed — + // run concurrently. + const [ownerMagpulMode, [withCompat]] = await Promise.all([ + resolveOwnerMagpulMode(db, row.ownerId), + attachCompatibility(db, actorId, [row]), + ]); // Return the viewer's permission alongside the record so the caller doesn't // re-resolve it (one query, no read-vs-permission race between two calls). - return { magazine: withCompat, permission }; + return { magazine: withCompat, permission, ownerMagpulMode }; } /**