From cc1f8ff9fde6a053761e188ea6138ab431734374 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Wed, 26 Aug 2026 11:07:06 -0400 Subject: [PATCH] Print a guide light, whatever the screen theme is Printing a dark-mode page lays a full page of toner down for the background and knocks the text out of it in white. On a shopfloor printer that is a cartridge for a handful of guides, and the result is harder to read than the plain version. Somebody would have found this the expensive way. The theme tokens are redefined for print only, in an unscoped block because overriding them has to reach :root. Every guide element already renders through those variables rather than literal colours, so this is a dozen lines instead of a rewrite - the same discipline that made dark mode work without dark-mode code. Backgrounds are dropped rather than printed grey: a callout is identified by its rule and its heading, which cost nothing. Checkboxes print EMPTY even where they are ticked on screen, because a printed guide is a worksheet somebody carries to the bay, and one person's progress is not part of the procedure. The footer gains the print date beside the version, so a stale copy pinned to a noticeboard can be spotted. Also corrects the model and migration found by running this against a real database rather than SQLite: BaseModel contributes createddate, modifieddate and isactive to every table and the migration never created them, so the first read failed on unknown columns; the model had also declared duplicate timestamps on top, now removed with the API field names kept stable. And the slug is 191 characters, not 300 - it is UNIQUE-indexed, MySQL caps an index key at 767 bytes, and utf8mb4 is 4 bytes per character. --- plugins/guides/api/routes.py | 4 +- .../frontend/components/GuideBlocks.vue | 16 ++++-- plugins/guides/frontend/views/GuideView.vue | 51 +++++++++++++++++++ .../versions/0001_guides_baseline.py | 26 ++++++++-- plugins/guides/models/guide.py | 26 ++++++---- plugins/guides/services/authoring.py | 1 - 6 files changed, 103 insertions(+), 21 deletions(-) diff --git a/plugins/guides/api/routes.py b/plugins/guides/api/routes.py index 293af01b..ac59426c 100644 --- a/plugins/guides/api/routes.py +++ b/plugins/guides/api/routes.py @@ -84,7 +84,7 @@ def _slugify(title, guideid=None): somebody pasted into a work order months ago. """ base = re.sub(r'[^a-z0-9]+', '-', (title or '').lower()).strip('-') or 'guide' - base = base[:200] + base = base[:180] # room for a '-2' style suffix inside the 191 cap candidate = base suffix = 2 while True: @@ -135,7 +135,7 @@ def list_guides(): Guide.createdby == mine, )) - rows = query.order_by(Guide.updatedat.desc()).all() + rows = query.order_by(Guide.modifieddate.desc()).all() out = [] for guide in rows: diff --git a/plugins/guides/frontend/components/GuideBlocks.vue b/plugins/guides/frontend/components/GuideBlocks.vue index 941a1457..3ddd31b9 100644 --- a/plugins/guides/frontend/components/GuideBlocks.vue +++ b/plugins/guides/frontend/components/GuideBlocks.vue @@ -230,11 +230,21 @@ async function copy (block) { .gb-table th { background: var(--surface-2, rgba(127,127,127,0.08)); } @media print { - /* Paper has no hover, no clipboard and no theme. */ + /* Paper has no hover, no clipboard and no theme. The light-mode token + overrides live in GuideView.vue, which can reach :root; this block is only + about what belongs on paper. */ .gb-copy { display: none; } - .gb-step, .gb-callout, .gb-code, .gb-figure { break-inside: avoid; } - .gb-check input { /* keep the box, printed empty, so it can be ticked by hand */ } + .gb-step, .gb-callout, .gb-code, .gb-figure, .gb-check { break-inside: avoid; } + + /* Checkboxes print EMPTY on purpose, even where they are ticked on screen: + a printed guide is a worksheet somebody carries to the bay and ticks by + hand, and one person's progress is not part of the procedure. */ .gb-done { opacity: 1; text-decoration: none; } + .gb-check input { appearance: none; -webkit-appearance: none; + width: 0.85rem; height: 0.85rem; border: 1px solid #000; + border-radius: 2px; background: #fff; } + .gb-code pre { white-space: pre-wrap; } + .gb-stepnum { color: #000; } } diff --git a/plugins/guides/frontend/views/GuideView.vue b/plugins/guides/frontend/views/GuideView.vue index fb2e61c0..e0ff3686 100644 --- a/plugins/guides/frontend/views/GuideView.vue +++ b/plugins/guides/frontend/views/GuideView.vue @@ -218,3 +218,54 @@ watch(() => route.params.slug, load) .gv-foot { border-top: 1px solid #999; } } + + diff --git a/plugins/guides/migrations/versions/0001_guides_baseline.py b/plugins/guides/migrations/versions/0001_guides_baseline.py index 2ebd51f1..8964c3a7 100644 --- a/plugins/guides/migrations/versions/0001_guides_baseline.py +++ b/plugins/guides/migrations/versions/0001_guides_baseline.py @@ -30,7 +30,9 @@ def upgrade(): 'guides', sa.Column('guideid', sa.Integer(), nullable=False), sa.Column('title', sa.String(length=300), nullable=False), - sa.Column('slug', sa.String(length=300), nullable=False), + # 191 because this is UNIQUE-indexed and MySQL caps an index key at + # 767 bytes; utf8mb4 is 4 bytes per character. + sa.Column('slug', sa.String(length=191), nullable=False), sa.Column('summary', sa.String(length=1000)), sa.Column('tags', sa.String(length=500)), sa.Column('status', sa.String(length=20), nullable=False, @@ -41,9 +43,13 @@ def upgrade(): sa.Column('publishedrevisionid', sa.Integer()), sa.Column('createdby', sa.Integer()), sa.Column('updatedby', sa.Integer()), - sa.Column('createdat', sa.DateTime(), nullable=False), - sa.Column('updatedat', sa.DateTime(), nullable=False), sa.Column('publishedat', sa.DateTime()), + # BaseModel contributes these to every model in the app; a plugin + # migration has to spell them out or the table is missing columns + # the ORM selects on every query. + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), sa.Column('revision', sa.Integer(), nullable=False, server_default='1'), sa.PrimaryKeyConstraint('guideid'), sa.UniqueConstraint('slug', name='uq_guides_slug'), @@ -65,7 +71,12 @@ def upgrade(): sa.Column('contenthash', sa.String(length=64), nullable=False), sa.Column('note', sa.String(length=500)), sa.Column('createdby', sa.Integer()), - sa.Column('createdat', sa.DateTime(), nullable=False), + # BaseModel contributes these to every model in the app; a plugin + # migration has to spell them out or the table is missing columns + # the ORM selects on every query. + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), sa.PrimaryKeyConstraint('guiderevisionid'), sa.ForeignKeyConstraint(['guideid'], ['guides.guideid'], ondelete='CASCADE'), @@ -85,7 +96,12 @@ def upgrade(): if op.get_bind().dialect.name == 'mysql' else sa.LargeBinary(), nullable=False), sa.Column('uploadedby', sa.Integer()), - sa.Column('createdat', sa.DateTime(), nullable=False), + # BaseModel contributes these to every model in the app; a plugin + # migration has to spell them out or the table is missing columns + # the ORM selects on every query. + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), sa.PrimaryKeyConstraint('guideimageid'), sa.ForeignKeyConstraint(['guideid'], ['guides.guideid'], ondelete='CASCADE'), diff --git a/plugins/guides/models/guide.py b/plugins/guides/models/guide.py index 6cbda923..a3587798 100644 --- a/plugins/guides/models/guide.py +++ b/plugins/guides/models/guide.py @@ -74,7 +74,12 @@ class Guide(BaseModel): # derived on read so an edited title cannot silently break a link somebody # pasted into a work order. title = db.Column(db.String(300), nullable=False) - slug = db.Column(db.String(300), nullable=False, unique=True, index=True) + # 191, not 300: this column is UNIQUE-indexed, and MySQL caps an index key + # at 767 bytes. utf8mb4 stores up to 4 bytes per character, so 191 * 4 = + # 764 is the longest indexable string - 300 produces + # "Specified key was too long" the moment the migration runs on a real + # database, having passed every SQLite test. + slug = db.Column(db.String(191), nullable=False, unique=True, index=True) # One line, shown in the list and in search results. summary = db.Column(db.String(1000)) @@ -114,11 +119,12 @@ class Guide(BaseModel): use_alter=True, name='fk_guides_publishedrevision')) + # createddate / modifieddate / isactive come from BaseModel - no second set + # of timestamps on top of them, or the next person has to work out which + # pair is authoritative. publishedat is ours because "when did readers start + # seeing this" is a different fact from "when was the row last touched". createdby = db.Column(db.Integer, db.ForeignKey('users.userid')) updatedby = db.Column(db.Integer, db.ForeignKey('users.userid')) - createdat = db.Column(db.DateTime, nullable=False, default=_utcnow) - updatedat = db.Column(db.DateTime, nullable=False, default=_utcnow, - onupdate=_utcnow) publishedat = db.Column(db.DateTime) # The working copy's version number. Bumped only when a save actually @@ -161,8 +167,10 @@ class Guide(BaseModel): 'tags': self.taglist(), 'status': self.status, 'revision': self.revision, - 'createdat': self.createdat.isoformat() + 'Z' if self.createdat else None, - 'updatedat': self.updatedat.isoformat() + 'Z' if self.updatedat else None, + # API names stay createdat/updatedat: the column names are a + # storage detail, and renaming a payload field breaks callers. + 'createdat': self.createddate.isoformat() + 'Z' if self.createddate else None, + 'updatedat': self.modifieddate.isoformat() + 'Z' if self.modifieddate else None, 'publishedat': (self.publishedat.isoformat() + 'Z' if self.publishedat else None), } @@ -212,7 +220,6 @@ class GuideRevision(BaseModel): note = db.Column(db.String(500)) createdby = db.Column(db.Integer, db.ForeignKey('users.userid')) - createdat = db.Column(db.DateTime, nullable=False, default=_utcnow) @property def blocks(self): @@ -234,7 +241,7 @@ class GuideRevision(BaseModel): 'tags': [t.strip() for t in (self.tags or '').split(',') if t.strip()], 'note': self.note, 'createdby': self.createdby, - 'createdat': self.createdat.isoformat() + 'Z' if self.createdat else None, + 'createdat': self.createddate.isoformat() + 'Z' if self.createddate else None, 'shorthash': (self.contenthash or '')[:12], } if includeblocks: @@ -268,7 +275,6 @@ class GuideImage(BaseModel): data = db.Column(db.LargeBinary(length=(2 ** 24) - 1), nullable=False) uploadedby = db.Column(db.Integer, db.ForeignKey('users.userid')) - createdat = db.Column(db.DateTime, nullable=False, default=_utcnow) def to_dict(self): return { @@ -278,5 +284,5 @@ class GuideImage(BaseModel): 'mimetype': self.mimetype, 'bytesize': self.bytesize, 'url': '/api/guides/images/{}'.format(self.guideimageid), - 'createdat': self.createdat.isoformat() + 'Z' if self.createdat else None, + 'createdat': self.createddate.isoformat() + 'Z' if self.createddate else None, } diff --git a/plugins/guides/services/authoring.py b/plugins/guides/services/authoring.py index d645d2f0..1afd4389 100644 --- a/plugins/guides/services/authoring.py +++ b/plugins/guides/services/authoring.py @@ -52,7 +52,6 @@ def save(guide, title=None, summary=None, tags=None, blocks=None, note=None, guide.revision = (latest.revision + 1) if latest else 1 guide.updatedby = userid - guide.updatedat = _utcnow() revision = GuideRevision( guideid=guide.guideid,