Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions app/(app)/magazines/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -76,7 +78,7 @@ export default async function MagazineDetailPage({ params }: PageProps) {
caliberSuggestions={caliberSuggestions}
prefixOptions={prefixData.prefixes}
prefixNextStart={prefixData.nextStart}
magpulMode={user.magpulMode}
ownerMagpulMode={ownerMagpulMode}
/>
);
}
156 changes: 156 additions & 0 deletions app/(app)/magazines/dot-matrix-label.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Callout tone="destructive">
{buildDoesNotFitMessage(result.cellCountVerified)}
</Callout>
);
}

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 (
<div className="space-y-2">
<svg
role="img"
aria-label={buildAriaLabel(cellCount, characters)}
width={width}
height={CELL_HEIGHT_PX}
viewBox={`0 0 ${width} ${CELL_HEIGHT_PX}`}
>
{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 (
<circle
// biome-ignore lint/suspicious/noArrayIndexKey: (cell, row, column) is the dot's stable identity in the fixed, never-reordered glyph grid.
key={`${cellIndex}-${rowIndex}-${columnIndex}`}
cx={cx}
cy={cy}
r={diameter / 2}
className={
painted ? "fill-dot-painted" : "fill-dot-unpainted"
}
/>
);
}),
);
})}
</svg>
{!cellCountVerified ? (
<Callout tone="neutral">{MODEL_NOT_RECOGNIZED_CAVEAT}</Callout>
) : null}
</div>
);
}
21 changes: 18 additions & 3 deletions app/(app)/magazines/magazine-detail-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -34,7 +35,7 @@ interface MagazineDetailViewProps {
caliberSuggestions: string[];
prefixOptions: string[];
prefixNextStart: Record<string, number>;
magpulMode: boolean;
ownerMagpulMode: boolean;
}

export function MagazineDetailView({
Expand All @@ -44,7 +45,7 @@ export function MagazineDetailView({
caliberSuggestions,
prefixOptions,
prefixNextStart,
magpulMode,
ownerMagpulMode,
}: MagazineDetailViewProps) {
const router = useRouter();
const [editing, setEditing] = useState(false);
Expand Down Expand Up @@ -131,7 +132,7 @@ export function MagazineDetailView({
caliberSuggestions={caliberSuggestions}
prefixOptions={prefixOptions}
prefixNextStart={prefixNextStart}
magpulMode={magpulMode}
magpulMode={ownerMagpulMode}
onDone={() => {
setEditing(false);
router.refresh();
Expand Down Expand Up @@ -194,6 +195,20 @@ export function MagazineDetailView({
}
/>
</dl>
{/* Below the field list, closest to the Label row it paints
(R13/R14). Not spliced mid-<dl>: that would require breaking
the list into two <dl>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. */}
<DotMatrixLabel
label={magazine.label}
brandModel={magazine.brandModel}
ownerMagpulMode={ownerMagpulMode}
/>
</Card>
)}

Expand Down
17 changes: 17 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading