diff --git a/backend/toolshed/api/inventory.py b/backend/toolshed/api/inventory.py index 40f2b97..833c00e 100644 --- a/backend/toolshed/api/inventory.py +++ b/backend/toolshed/api/inventory.py @@ -149,6 +149,10 @@ class StorageLocationViewSet(viewsets.ModelViewSet): serializer_class = StorageLocationSerializer authentication_classes = [SignatureAuthentication] permission_classes = [IsAuthenticated] + # Detail routes address a location by its owner-scoped id, not the internal row id. See + # docs/implementation.md#inventory-detail-routes-use-owner-scoped-ids. + lookup_field = 'id' + lookup_url_kwarg = 'pk' def get_queryset(self): if type(self.request.user) == KnownIdentity and self.request.user.user.exists(): diff --git a/backend/toolshed/models.py b/backend/toolshed/models.py index 6fdb9ff..6136a86 100644 --- a/backend/toolshed/models.py +++ b/backend/toolshed/models.py @@ -169,7 +169,27 @@ class ItemTag(models.Model): inventory_item = models.ForeignKey(InventoryItem, on_delete=models.CASCADE) +class OwnerStorageLocationSequence(models.Model): + """Tracks the last StorageLocation id handed out per owner for sequential, gapless allocation + (see StorageLocation.create_for_owner).""" + owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='+', unique=True) + last_id = models.PositiveIntegerField(default=0) + + @classmethod + def allocate(cls, *, owner): + with transaction.atomic(): + seq, _ = cls.objects.select_for_update().get_or_create(owner=owner) + seq.last_id += 1 + seq.save(update_fields=['last_id']) + return seq.last_id + + class StorageLocation(models.Model): + internal_id = models.AutoField(primary_key=True) + # Externally visible id, sequential/gapless within the owner's own locations (see + # OwnerStorageLocationSequence), never internal_id; always allocate via create_for_owner, not + # .objects.create(). + id = models.PositiveIntegerField(editable=False) name = models.CharField(max_length=255) description = models.TextField(null=True, blank=True) category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, blank=True, @@ -177,10 +197,23 @@ class StorageLocation(models.Model): parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children') owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='storage_locations') + class Meta: + constraints = [ + models.UniqueConstraint(fields=['owner', 'id'], name='storagelocation_unique_owner_scoped_id'), + ] + def __str__(self): parent = str(self.parent) + "/" if self.parent else "" return parent + self.name + @classmethod + def create_for_owner(cls, *, owner, **kwargs): + """The only supported way to create a StorageLocation: atomically allocates the next id + for this owner's scope.""" + with transaction.atomic(): + next_id = OwnerStorageLocationSequence.allocate(owner=owner) + return cls.objects.create(owner=owner, id=next_id, **kwargs) + class WorkflowInstance(models.Model): slug = models.CharField(max_length=255) diff --git a/backend/toolshed/offlinedata.py b/backend/toolshed/offlinedata.py index 1175e63..ee9f383 100644 --- a/backend/toolshed/offlinedata.py +++ b/backend/toolshed/offlinedata.py @@ -339,13 +339,17 @@ def import_locations(user, data): if category_path: category = get_or_create_category(category_path) - location, _ = StorageLocation.objects.update_or_create( - owner=user, name=name, parent=parent, - defaults={ - 'description': row.get('description', '') or '', - 'category': category, - }, - ) + defaults = { + 'description': row.get('description', '') or '', + 'category': category, + } + try: + location = StorageLocation.objects.get(owner=user, name=name, parent=parent) + for field, value in defaults.items(): + setattr(location, field, value) + location.save(update_fields=list(defaults.keys())) + except StorageLocation.DoesNotExist: + location = StorageLocation.create_for_owner(owner=user, name=name, parent=parent, **defaults) resolved_by_path[path] = location imported += 1 except Exception as error: diff --git a/backend/toolshed/serializers.py b/backend/toolshed/serializers.py index 4c864f3..369222f 100644 --- a/backend/toolshed/serializers.py +++ b/backend/toolshed/serializers.py @@ -1,3 +1,4 @@ +from django.core.exceptions import ObjectDoesNotExist from rest_framework import serializers from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming, Group, GroupInviteIncoming from authentication.serializers import OwnerSerializer, GroupOwnerSerializer @@ -150,15 +151,50 @@ class CategorySerializer(serializers.ModelSerializer): return resolve_category_handle(data.split("/")[-1]) +class OwnerScopedPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField): + """Resolves/represents by the owner-scoped `id` rather than the model's internal pk, scoped to + the requesting user - StorageLocation.parent points at another StorageLocation, whose publicly + visible identity is now the owner-scoped id (see StorageLocation.create_for_owner), not + internal_id.""" + + def use_pk_only_optimization(self): + # False: to_representation needs the owner-scoped `id`, not just the internal pk that the + # PKOnlyObject optimization would otherwise limit us to. + return False + + def get_queryset(self): + queryset = super().get_queryset() + request = self.context.get('request') + if request is not None and type(request.user) == KnownIdentity and request.user.user.exists(): + return queryset.filter(owner=request.user.user.get()) + return queryset.none() + + def to_internal_value(self, data): + queryset = self.get_queryset() + try: + if isinstance(data, bool): + raise TypeError + return queryset.get(id=data) + except ObjectDoesNotExist: + self.fail('does_not_exist', pk_value=data) + except (TypeError, ValueError): + self.fail('incorrect_type', data_type=type(data).__name__) + + def to_representation(self, value): + return value.id + + class StorageLocationSerializer(serializers.ModelSerializer): owner = OwnerSerializer(read_only=True) category = serializers.CharField(required=False, allow_null=True, allow_blank=True) + parent = OwnerScopedPrimaryKeyRelatedField(queryset=StorageLocation.objects.all(), required=False, + allow_null=True) path = serializers.SerializerMethodField() class Meta: model = StorageLocation fields = ['id', 'name', 'description', 'path', 'category', 'owner', 'parent'] - read_only_fields = ['path'] + read_only_fields = ['id', 'path'] @staticmethod def get_path(obj): @@ -166,6 +202,9 @@ class StorageLocationSerializer(serializers.ModelSerializer): return StorageLocationSerializer.get_path(obj.parent) + "/" + obj.name return obj.name + def create(self, validated_data): + return StorageLocation.create_for_owner(**validated_data) + class ItemPropertySerializer(serializers.ModelSerializer): property = PropertySerializer(read_only=True) @@ -195,6 +234,8 @@ class InventoryItemSerializer(serializers.ModelSerializer): properties = ItemPropertySerializer(many=True, required=False, source='itemproperty_set') category = CategorySerializer(required=False, allow_null=True) files = FileSerializer(many=True, read_only=True) + storage_location = OwnerScopedPrimaryKeyRelatedField(queryset=StorageLocation.objects.all(), required=False, + allow_null=True) class Meta: model = InventoryItem diff --git a/backend/toolshed/tests/fixtures.py b/backend/toolshed/tests/fixtures.py index 942de7e..69c2e42 100644 --- a/backend/toolshed/tests/fixtures.py +++ b/backend/toolshed/tests/fixtures.py @@ -49,12 +49,12 @@ class InventoryTestMixin(CategoryTestMixin, TagTestMixin, PropertyTestMixin): class LocationTestMixin: def prepare_locations(self): - self.f['loc1'] = StorageLocation.objects.create(name='loc1', owner=self.f['local_user1']) - self.f['loc2'] = StorageLocation.objects.create(name='loc2', owner=self.f['local_user1'], - category=self.f['cat1']) - self.f['loc3'] = StorageLocation.objects.create(name='loc3', owner=self.f['local_user1'], parent=self.f['loc1']) - self.f['loc4'] = StorageLocation.objects.create(name='loc4', owner=self.f['local_user1'], parent=self.f['loc1'], - category=self.f['cat1']) + self.f['loc1'] = StorageLocation.create_for_owner(name='loc1', owner=self.f['local_user1']) + self.f['loc2'] = StorageLocation.create_for_owner(name='loc2', owner=self.f['local_user1'], + category=self.f['cat1']) + self.f['loc3'] = StorageLocation.create_for_owner(name='loc3', owner=self.f['local_user1'], parent=self.f['loc1']) + self.f['loc4'] = StorageLocation.create_for_owner(name='loc4', owner=self.f['local_user1'], parent=self.f['loc1'], + category=self.f['cat1']) class WorkflowTestMixin: diff --git a/frontend/src/assets/fonts/label/Inter-Regular.woff2 b/frontend/src/assets/fonts/label/Inter-Regular.woff2 new file mode 100644 index 0000000..d228a4a Binary files /dev/null and b/frontend/src/assets/fonts/label/Inter-Regular.woff2 differ diff --git a/frontend/src/components/CameraScanner.vue b/frontend/src/components/CameraScanner.vue index a6cb277..a786981 100644 --- a/frontend/src/components/CameraScanner.vue +++ b/frontend/src/components/CameraScanner.vue @@ -32,13 +32,156 @@ diff --git a/frontend/src/components/LabelLayoutPreview.vue b/frontend/src/components/LabelLayoutPreview.vue index d697178..e0a68b3 100644 --- a/frontend/src/components/LabelLayoutPreview.vue +++ b/frontend/src/components/LabelLayoutPreview.vue @@ -90,12 +90,12 @@ background: #fff; } -/* Corner badge flagging *why* a layout that's otherwise available (fields filled in) still - failed to render - e.g. label.js's "bigger code than the tape allows" or "doesn't fit on this - tape" errors (see redraw()'s catch). Shows the short label directly (e.g. "too big") rather than - just an icon, so the reason reads at a glance; the full sentence is still the title tooltip. Not - shown for the more common "fields not filled in yet" case (see isSelectable/unavailableReason) - since that's already conveyed by the greyed-out thumbnail. */ +/* Corner badge flagging *why* a layout failed to render - a capacity error (label.js's "bigger + code than the tape allows"/"doesn't fit on this tape") or label-layouts.js's templateContent + throwing "missing data" because a field it needs isn't filled in yet (see redraw()'s catch). + Shows the short label directly (e.g. "too big", "missing data") rather than just an icon, so the + reason reads at a glance; the full sentence is still the title tooltip. Layered on top of the + greyed-out/unselectable state isSelectable/unavailableReason already apply for the same case. */ .template-thumb-warning { position: absolute; top: .35rem; @@ -276,17 +276,8 @@ export default { if (!canvas) { continue; } - const content = templateContent(t, this.fields); - if (!this.isAvailable(t) || !content) { - canvas.width = 1; - canvas.height = 1; - delete this.failed[t.id]; - delete this.warned[t.id]; - delete this.qrInfo[t.id]; - delete this.textInfo[t.id]; - continue; - } try { + const content = templateContent(t, this.fields); const {warnings, qrInfo, textInfo} = this.tape ? drawLabel(canvas, this.tape, content, "along") : drawFallbackLabel(canvas, content, "along"); diff --git a/frontend/src/label-layouts.js b/frontend/src/label-layouts.js index 018b69d..84dc502 100644 --- a/frontend/src/label-layouts.js +++ b/frontend/src/label-layouts.js @@ -86,14 +86,39 @@ export const LABEL_TEMPLATES = [ }, { id: "rmqr-url", name: "rMQR Token", description: "The code with the encoded text printed next to it.", - required_vars: ["shortUrl","itemId"], tags: ["external"], - layout: [{type: "rmqr", content: c => c.shortUrl},GAP,{type: "text", content: c => c.itemId?.toString().padStart(4, "0")}] + required_vars: ["shortUrl", "itemId"], tags: ["external"], + layout: [{type: "rmqr", content: c => c.shortUrl}, GAP, { + type: "text", + content: c => c.itemId?.toString().padStart(4, "0") + }] }, { id: "mqr-tokem-id", name: "rMQR Token", description: "The code with the encoded text printed next to it.", - required_vars: ["shortId","itemId"], tags: ["internal"], - layout: [{type: "mqr", content: c => c.shortId},GAP,{type: "text", content: c => c.itemId?.toString().padStart(4, "0")}] - },...QR_ONLY_TEMPLATES, + required_vars: ["shortId", "itemId"], tags: ["internal"], + layout: [{type: "mqr", content: c => c.shortId}, GAP, { + type: "text", + content: c => c.itemId?.toString().padStart(4, "0") + }] + }, + { + id: "location-rmqr-url", name: "rMQR Token", description: "The code with the encoded text printed next to it.", + required_vars: ["shortUrl", "locationId"], tags: ["external"], + layout: [{type: "rmqr", content: c => c.shortUrl}, GAP, { + type: "text", + content: c => c.locationId?.toString().padStart(4, "0") + }] + }, + { + id: "location-mqr-tokem-id", + name: "rMQR Token", + description: "The code with the encoded text printed next to it.", + required_vars: ["shortId", "locationId"], + tags: ["internal"], + layout: [{type: "mqr", content: c => c.shortId}, GAP, { + type: "text", + content: c => c.locationId?.toString().padStart(4, "0") + }] + }, ...QR_ONLY_TEMPLATES, { id: "qr-text", name: "QR code + text", description: "The code with the encoded text printed next to it.", @@ -110,7 +135,7 @@ export const LABEL_TEMPLATES = [ id: "id-qr-text-vertical", name: "ID + QR code + text below", description: "The code with the encoded text printed below it.", required_vars: ["itemId", "text", "userHandle"], - layout: [[{type: "text", content: c => "Item: "+c.itemId}, GAP, { + layout: [[{type: "text", content: c => "Item: " + c.itemId}, GAP, { type: "qr", content: c => c.text }, GAP, {type: "text", content: c => c.userHandle}]] @@ -148,6 +173,17 @@ export const LABEL_TEMPLATES = [ required_vars: ["userHandle", "itemId"], tags: ["internal"], layout: [{type: "text", content: c => [c.userHandle, c.itemId]}] }, + { + id: "location-id", name: "Location ID", description: "Just the bare storage location id, as text only.", + required_vars: ["locationId"], tags: ["internal"], + layout: [{type: "text", content: c => c.locationId}] + }, + { + id: "owner-id-text-location", name: "Owner + location ID", + description: "The owner's handle and the location id, as two lines of text - no code.", + required_vars: ["userHandle", "locationId"], tags: ["internal"], + layout: [{type: "text", content: c => [c.userHandle, c.locationId]}] + }, { id: "item-url-qr-handle", name: "Item URL + handle", description: "Scannable item URL, with the item's compact handle printed alongside.", @@ -272,14 +308,15 @@ export function templateIsAvailable(t, fields) { } // Resolves t's content leaves against fields into a tree for label.js's -// drawLabel/drawFallbackLabel, or null if nothing to render yet. +// drawLabel/drawFallbackLabel. Throws the same {message, short} shape as label.js's own +// capacity errors (see docs/implementation.md#missing-data-is-a-templatecontent-error) when a +// leaf isn't resolved yet, rather than returning null, so a caller's single catch around +// drawLabel/drawFallbackLabel handles both without a separate isAvailable pre-check. export function templateContent(t, fields) { - const tree = mapTree(t.layout, leaf => leaf.type === "empty" ? leaf : {...leaf, value: leaf.content(fields)}); - let hasContent = false; - walkLeaves(tree, leaf => { - if (leaf.type !== "empty" && leaf.value && (!Array.isArray(leaf.value) || leaf.value.some(Boolean))) { - hasContent = true; - } - }); - return hasContent ? tree : null; + if (!templateIsAvailable(t, fields)) { + const err = new Error("This layout needs more fields filled in before it can be drawn."); + err.short = "missing data"; + throw err; + } + return mapTree(t.layout, leaf => leaf.type === "empty" ? leaf : {...leaf, value: leaf.content(fields)}); } diff --git a/frontend/src/label.js b/frontend/src/label.js index e2ec350..ac26a18 100644 --- a/frontend/src/label.js +++ b/frontend/src/label.js @@ -81,14 +81,18 @@ const TEXT_REFERENCE_PX = 100; /* font size text leaves measure their natural a // Below 10px, a general-purpose sans-serif gets illegible, so drawTextLeaf switches to a bitmap // font (Tom Thumb/Silkscreen) instead; see the empirical rationale (font choice, `scale`, and why -// sizes aren't dpi-adjusted) at docs/implementation.md#pixel-font-selection. -const PIXEL_FONT_TIERS = [ - {belowPx: 8, family: "Tom Thumb", scale: 3.2}, - {belowPx: 10, family: "Silkscreen"}, +// sizes aren't dpi-adjusted) at docs/implementation.md#pixel-font-selection. The 10px+ tier names +// Inter explicitly (see ../scss/_label-fonts.scss) rather than falling back to the CSS generic +// "sans-serif" keyword, which resolves to a different real font per browser/OS and would make the +// same label print differently depending on where it was rendered from. +const FONT_TIERS = [ + {belowPx: 8, family: "Tom Thumb", scale: 3.2, pixel: true}, + {belowPx: 10, family: "Silkscreen", pixel: true}, + {belowPx: Infinity, family: "Inter"}, ]; function fontFamilyFor(fontPx) { - return PIXEL_FONT_TIERS.find(t => fontPx < t.belowPx) ?? {family: "sans-serif"}; + return FONT_TIERS.find(t => fontPx < t.belowPx); } // Below this font size (px) or physical height (mm) - Tom Thumb's real-Chromium-tested legibility @@ -291,11 +295,12 @@ function checkTextSizes(node, referencePx, pxPerMm, warnings, textInfo) { } } -// Always measures in sans-serif at the reference size, since the eventual font (see -// PIXEL_FONT_TIERS) isn't known until layoutTree sizes the box this aspect ratio feeds into; the -// resulting mismatch is invisible in practice, and checkTextSizes still catches real failures. +// Always measures in Inter at the reference size, since the eventual font (see FONT_TIERS) isn't +// known until layoutTree sizes the box this aspect ratio feeds into; the resulting mismatch (when +// a pixel font tier ends up chosen instead) is invisible in practice, and checkTextSizes still +// catches real failures. function measureTextBlock(ctx, lines, referencePx) { - ctx.font = `${referencePx}px sans-serif`; + ctx.font = `${referencePx}px "Inter"`; const width = Math.max(...lines.map(line => ctx.measureText(line).width)); const height = referencePx * 1.15 * lines.length; return {width, height}; @@ -342,20 +347,18 @@ function drawQrLeaf(ctx, node) { // there's no "too small to draw" case left to special-case here. function drawTextLeaf(ctx, node, referencePx) { const fontPx = effectiveFontPx(node, referencePx); - const {family, scale = 1} = fontFamilyFor(fontPx); - const isPixelFont = family !== "sans-serif"; + const {family, scale = 1, pixel: isPixelFont = false} = fontFamilyFor(fontPx); // Position (not size - fontPx is already a whole pixel) still needs snapping for pixel fonts - // to stay grid-aligned, since node.box.x/y are ordinary (fractional) layout math; sans-serif is + // to stay grid-aligned, since node.box.x/y are ordinary (fractional) layout math; Inter is // left exact since anti-aliasing handles fractional positions fine. const snap = isPixelFont ? Math.round : (v) => v; - // `scale` (Tom Thumb only, see PIXEL_FONT_TIERS above) corrects the size handed to ctx.font - // for its real ink; fontPx itself stays the logical size used for centering/stacking math. + // `scale` (Tom Thumb only, see FONT_TIERS above) corrects the size handed to ctx.font for its + // real ink; fontPx itself stays the logical size used for centering/stacking math. ctx.font = `${fontPx * scale}px "${family}"`; // Canvas text silently falls back if drawn before a not-yet-loaded font resolves, unlike DOM - // text. See docs/implementation.md#canvas-font-loading. - if (isPixelFont) { - document.fonts.load(ctx.font); - } + // text. See docs/implementation.md#canvas-font-loading. Every tier now names a real, + // self-hosted webfont (see FONT_TIERS above), so this always applies, not just to pixel fonts. + document.fonts.load(ctx.font); ctx.textAlign = "center"; const centerX = snap(node.box.x + node.box.width / 2); const lineHeight = node.box.height / node.lines.length; @@ -527,9 +530,9 @@ export function drawFallbackLabel(canvas, content, orientation = "along") { // print label should show/encode; keyed by `kind` so each kind's format is defined in one place. export const LABEL_CONTENT_BUILDERS = { // The self-contained Item URL (see docs/design-in-progress/items-labels.md), built from just - // the prefill's {userHandle, id}; the short link (Print.vue's `shortUrl`) needs an async store + // the prefill's {userHandle, item}; the short link (Print.vue's `shortUrl`) needs an async store // lookup, so it stays a separate field/template rather than being baked in here. - "item": ({userHandle, id}) => `${window.location.origin}/i/${encodeHandleForUrl(userHandle)}/${id}`, + "item": ({userHandle, item}) => `${window.location.origin}/i/${encodeHandleForUrl(userHandle)}/${item}`, // Storage locations have no long-form URL route yet (see router.js), so `text` starts blank; // the short link and any future location template still work via the base vars below. }; @@ -542,7 +545,7 @@ export function buildLabelContent(prefill) { return build ? build(prefill.components) : ""; } -// Splits a prefill's {userHandle, id} into label-layouts.js's user/domain base vars (the same way +// Splits a prefill's {userHandle, item/location} into label-layouts.js's user/domain base vars (the same way // store.js's lookupServer does), tagging on whichever id field the resource's templates key // required_vars by. function splitUserHandle(userHandle) { @@ -560,19 +563,19 @@ function splitUserHandle(userHandle) { // computed live elsewhere (see DERIVED_VARS, Print.vue's `shortUrl`), and omitting a field // (rather than leaving it present-but-empty) signals "not available" to templateIsAvailable. const LABEL_FIELD_BUILDERS = { - "item": ({userHandle, id}) => { + "item": ({userHandle, item}) => { const split = splitUserHandle(userHandle); - if (!split || !id) { + if (!split || !item) { return {}; } - return {...split, itemId: String(id)}; + return {...split, itemId: String(item)}; }, - "storage-location": ({userHandle, id}) => { + "storage-location": ({userHandle, location}) => { const split = splitUserHandle(userHandle); - if (!split || !id) { + if (!split || !location) { return {}; } - return {...split, locationId: String(id)}; + return {...split, locationId: String(location)}; }, }; diff --git a/frontend/src/router.js b/frontend/src/router.js index d3f3597..9198495 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -139,7 +139,9 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, { meta: {requiresAuth: true}, props: route => { const {kind, ...components} = route.query; - return {prefill: kind ? {kind, components} : null}; + // queryFields lets any base var (e.g. ?text=...) be set directly, without going through + // a `kind` builder - see Print.vue's varValues. + return {prefill: kind ? {kind, components} : null, queryFields: components}; } }, { path: '/scan', diff --git a/frontend/src/scss/toolshed.scss b/frontend/src/scss/toolshed.scss index 22ce7f3..5be483d 100644 --- a/frontend/src/scss/toolshed.scss +++ b/frontend/src/scss/toolshed.scss @@ -94,6 +94,7 @@ $body-color: $gray-700; @import "dropdown"; @import "pixel-fonts"; @import "pixel-fonts-candidates"; +@import "label-fonts"; #root, body, html { height: 100%; diff --git a/frontend/src/views/Inventory.vue b/frontend/src/views/Inventory.vue index fd2808d..5adca48 100644 --- a/frontend/src/views/Inventory.vue +++ b/frontend/src/views/Inventory.vue @@ -154,7 +154,7 @@ export default { // See docs/implementation.md#print-link-shape-for-personal-items. printLinkFor(item) { if (!item.owner) return null - return {path: '/print', query: {kind: 'item', userHandle: item.owner, id: item.id}} + return {path: '/print', query: {kind: 'item', userHandle: item.owner, item: item.id}} }, }, async mounted() { diff --git a/frontend/src/views/InventoryDetail.vue b/frontend/src/views/InventoryDetail.vue index 6268ae2..0685771 100644 --- a/frontend/src/views/InventoryDetail.vue +++ b/frontend/src/views/InventoryDetail.vue @@ -51,7 +51,7 @@ Delete diff --git a/frontend/src/views/Print.vue b/frontend/src/views/Print.vue index 9fa4d78..21195bc 100644 --- a/frontend/src/views/Print.vue +++ b/frontend/src/views/Print.vue @@ -57,7 +57,7 @@
- Nine pixel/bitmap-style fonts found in frontend/public, rendered live from the "Text" + Ten pixel/bitmap-style fonts found in frontend/public, rendered live from the "Text" field above at a range of sizes so they can be judged the same way Tom Thumb/Silkscreen were. CodersCrux and 712Serif fail to load as web fonts at all - Chromium's OTS sanitizer rejects their cmap table - so their cells below @@ -86,7 +86,7 @@