Skip to content

Pick up the three things the rebase left behind - #125

Merged
openipc-ai merged 7 commits into
masterfrom
fix/missed-from-snapshot
Aug 28, 2026
Merged

Pick up the three things the rebase left behind#125
openipc-ai merged 7 commits into
masterfrom
fix/missed-from-snapshot

Conversation

@openipc-ai

Copy link
Copy Markdown
Collaborator

You asked whether the whole working tree was covered. It wasn't — this is what
the audit found.

Stacked on #124. Method: diff the June tree's 106 changed files against
everything the five PRs landed. 20 unaccounted for; 17 deliberate and
explained in those PRs (the concern upstream rewrote better, locale files for
languages no longer served, a Yarn 1 lockfile, a schema dump). Three were
not.

1. The merchandise page shows one T-shirt three times

Same image, same .shirt_1 keys, same link — pasted into three cards. It has
read as a catalogue of one product in triplicate on the live site for as long as
the file has existed. One card now, and a test that counts them.

2. The Open Wall didn't say what it is

It's in the navigation and the footer since #124, so it now gets visitors who
have never seen it and can't tell from a grid of stills whether these images
were collected or volunteered. A line under the heading says they were
volunteered, and that they're deleted after two days — which is
PurgeImagesJob::RETENTION, checked rather than assumed. en/ru/zh.

3. Six surviving pages had no tests at all

/green_life, /merchandise, /stages-of-firmware-development, /utilities,
and the partition and timer calculators are served by the app and were rendered
by no test whatsoever.

They're the pages nobody thinks about — and they render through the layout,
navbar and footer that #122 and #124 rewrote, so a mistake in any of those shows
up there first, or not at all. Ten pages are rendered now, each checked for its
own title and for translation missing, and held to the layout wrapper — which
also pins the other side of the content_for(:fullwidth) switch against pages
that depend on it.

Verification

check result
bin/rails test 303 runs, 1390 assertions, 0 failures (289 → 303)
i18n-tasks missing none
i18n-tasks unused 72, unchanged
rubocop on the new test 0 offences

The merchandise test fails on master (finds 3, expects 1), which is the check
worth repeating.

🤖 Generated with Claude Code

https://claude.ai/code/session_015hvzXBErEjEGMyRme2K3hi

openipc-ai and others added 6 commits August 25, 2026 15:02
The site's CSS was one 200-line file appended to Bootstrap, and the order it
was written in hid a bug. Our theme values landed *after* the Bootstrap import,
where Bootstrap's own !default declarations had already won, so $primary stayed
at Bootstrap's #0d6efd while :root separately declared --bs-blue: #4c60d8. The
page carried two blues: buttons and links in one, headings and the navbar in the
other. Nobody had written the wrong colour anywhere -- the file just could not
express the intent in that order.

So the entry point is now only an ordering statement: fonts, Bootstrap's
functions, our variables, Bootstrap, then our own partials. --bs-primary and
--bs-btn-bg both compile to #4c60d8 now. $blue is set alongside $primary because
--bs-blue is what the pre-relaunch rules colour headings with, and leaving it at
the default would have reproduced the same split from the other side.

The partials that arrive here are the ones that style pages which exist today.
Components and page styles belonging to the new pages come with those pages,
rather than sitting in the tree as dead CSS in the meantime. pages/_legacy.scss
holds the rules that belong to the pre-relaunch pages, kept verbatim and kept
together so the cutover can delete a file instead of picking rules apart. One of
them is the CSS that hides the Russian integrators from non-Russian visitors on
/introduction: the replacement is a Ruby helper, but until that page goes this
rule is the only thing enforcing it, and dropping it early would show that logo
wall to everyone.

The other legacy rule worth naming is the h2 title style. _base.scss gives
article headings the new treatment; the legacy selector is longer and therefore
wins, and it applies exactly to pages rendered inside the .container wrapper --
which is every page that is not full-bleed. The two rules split along the same
line the layout's fullwidth switch will.

FONTS. The stylesheet opened with three @import url() lines: two to
fonts.googleapis.com and one to cdn.jsdelivr.net. Every visitor announced
themselves to two third parties before the page could paint, and first paint sat
behind DNS lookups we do not control. This is the same argument that moved the
legacy logo off a maintainer's personal CDN in d60729a. IBM Plex and
bootstrap-icons are now served from public/fonts, subset to latin, latin-ext and
cyrillic; Chinese falls back to the system stack, because IBM Plex has no CJK
glyphs and the families that do are megabytes. The files are committed, since
public/ bypasses Sprockets and a generated file would have to exist before
assets:precompile in the image and before CI renders a page, for no benefit.
tools/copy-fonts.mjs regenerates them from the @fontsource packages and fails
loudly if a file the stylesheet asks for is missing.

JAVASCRIPT. One window.onload handler becomes modules under app/javascript/src.
That handler assigned window.onload rather than adding a listener, so any second
assignment would have silently replaced all of it, and it waited on every image
-- on the Open Wall, the whole gallery -- before anything became interactive.
It is DOMContentLoaded now.

The zoom initialiser called `new bootstrap.Modal(document.getElementById(...))`
unconditionally. Most pages have no zoomable image and so no #modalZoom, the
constructor threw on null, and because every initialiser shared the one handler,
the throw took external links, timestamps and confirmations down with it on
those pages. It returns early now.

@hotwired/turbo-rails and @hotwired/stimulus are removed: neither was ever
imported. The layout's data-turbo-track goes with them, having been inert. The
lockfile is regenerated with Yarn 4 -- it must never be rewritten by a v1 yarn,
which produces a format `yarn install --immutable` in the Dockerfile cannot read.

Bootstrap is imported per component rather than wholesale. 189.4 KB to 181.8 KB
unminified, which is a small win; the point is the import list, which now states
what the markup actually depends on. Audited against the views first: dropdown,
collapse, offcanvas, carousel and Modal are used, and nothing uses tooltip,
popover, tab, alert or scrollspy.

Verified: 231 runs, 873 assertions, 0 failures. Diffing the compiled selector
set against master's leaves exactly one difference -- label.required:after,
which is now spelled ::after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hvzXBErEjEGMyRme2K3hi
The <head> carried a title and nothing else. A link to openipc.org shared in
Telegram, in a forum post, or shown in a search result therefore appeared as a
bare URL with no summary and no image -- for a project whose reach is almost
entirely word of mouth between people posting links to each other.

Added: a description, an OG/Twitter card set, a canonical, and a theme colour.
Three of those have a detail worth keeping:

The canonical carries the path and not the query string. ?locale=ru is a
rendering of the same page rather than a different one, and pointing all three
translations at one canonical is what stops them competing with each other for
the same content.

og:image is absolute, because crawlers do not resolve a relative one, and it
lives in public/ rather than the asset pipeline. OG images are fetched once and
cached by URL by every consumer that has ever seen the link, so a fingerprint in
the name would invalidate those caches for no gain.

The two font faces every page renders body text in are preloaded. crossorigin is
required even though they are same-origin -- fonts are fetched in CORS mode, and
a preload without it is simply fetched a second time.

The navbar and footer move into layouts/ partials. Nothing about them changes:
same links, same order, same locale switcher, and the footer's icons still
resolve through asset_path, which is not cosmetic -- production runs with
config.assets.compile = false, so a literal /assets/... path is a 404, and
2ec9baa fixed exactly these lines. Extracting them now means the relaunch can
rewrite the navigation in one file a reviewer can read, instead of inside a diff
that also touches <head>.

<main> gains a content_for(:fullwidth) switch for pages that lay out their own
full-bleed sections. Flash messages keep the container in both branches, or they
would run edge to edge. Pages that set nothing keep the wrapper they have always
had, and a test holds that.

The tests are the ones that would have caught each way a <head> goes wrong: a
missing or duplicated description, a description that came back as "translation
missing", a canonical that kept the query string, a relative og:image, an
og-default.png that is not there, and a preload naming a font file the
stylesheet does not generate.

238 runs, 903 assertions, 0 failures. i18n-tasks reports no missing keys and
102 unused, unchanged -- the new key being used is what keeps that number flat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hvzXBErEjEGMyRme2K3hi
Seven pages -- home, get-started, low-latency, ecosystem, business, community,
donate -- plus the shared partials they are built from. They answer on their own
URLs from this commit, but the root route and the navigation still serve the
pre-relaunch structure, so nothing a visitor sees changes. The cutover is a
separate change, and this one can go to dev.openipc.org and be looked at first.

The copy is written in all three locales the site now serves. English and
Russian are as drafted; the Chinese was written for this commit and has not been
read by a native speaker -- it should be, before it is linked to. i18n-tasks
reports 347 keys in each of the three files and no missing translations.

Not everything from the draft survived contact with the current tree, and the
rendering tests are what found each one.

/get-started hardcoded four WebUI screenshots by filename --
webui/preview.jpg and three others. The gallery was reshot at twice the
resolution in 0f42514 and every one of those files stopped existing; the page
raised on render. It now takes them from WebuiGallery, the same manifest
/web-interface reads and the same one tools/webui-gallery photographs from, so
the two cannot disagree about what exists. Tile and full-resolution copy are
passed as a pair, which is what data-zoom in src/zoom.js is for.

Every link in the new copy that pointed at wiki.openipc.org now points at
github.com/OpenIPC/wiki. That host is retired; 2842ffa and 73c0961 removed the
last references to it and this copy would have put four of them back.

The partner wall moves out of the markup into PagesHelper, transcribed from
/introduction as it stands today rather than from the June draft, which was ten
entries out of date. Entries that page has commented out are commented out here
too, with their URLs, so nothing appears or disappears silently when
/introduction goes.

  REVIEW POINT: the Russian integrator block is currently commented out on
  /introduction, so no visitor sees any of it. Rendering it for :ru brings it
  back. That is what the relaunch plan asks for and it is gated to :ru as the
  maintainers asked, but it is a content decision -- say so if it should stay
  hidden.

Worth knowing: the CSS that was supposed to enforce that gate,
`html:not([lang="ru"]) article.introduction .col.logo.ru`, has never matched
anything. No logo on that page carries the `ru` class it selects on. The gate
being in Ruby now is what makes it testable, and there is a test.

/open-wall is uncommented. It was commented out in ed0e025, a bulk tidy-up,
while five places that redirect to it were left in place -- twice in
snapshots_controller, once in admin/snapshots_controller, and the breadcrumb on
three views. All five fell through the catch-all and answered a 302 to the
homepage. This exposes nothing new: `resources :snapshots` has served the same
gallery at /snapshots throughout.

Snapshot.latest_per_camera lifts the correlated subquery out of
SnapshotsController#index, because the homepage mosaic wants the same list and
two copies of that query is one too many.

pages.donate belonged to the band rendered under every page. It is now the
/donate page's, and the band's two keys moved to pages.donate_band -- otherwise
lazy lookup in the partial and the new page's title would have fought over the
same key, and the band would have lost its please_support string.

272 runs, 1223 assertions, 0 failures. Each page is rendered in each of the
three locales and checked for "translation missing"; every internal link on
them is followed and required not to land on the catch-all; the homepage is
rendered against an empty database, which is what a fresh checkout has. rubocop
is unchanged at 53 offences across the files this touches, and the two new files
add none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hvzXBErEjEGMyRme2K3hi
The root becomes pages#home, the navigation becomes Get Started | Hardware |
Low Latency | Ecosystem | For Business | Community, and the single copyright
line becomes a four-column footer. Everything that has been building up over the
last four changes is now what a visitor sees.

The pre-relaunch URLs redirect rather than disappear. They are in search
results, in forum posts and in the wiki, and none of that is ours to edit: 301
for the ones whose destination is settled, so what is indexed transfers instead
of competing, and 302 for /about alone, because it is meant to become a page of
its own and a 301 is cached by browsers indefinitely -- it would outlive the
decision. There is a test per redirect, and a test that every target resolves,
because a redirect to a path that has itself gone still answers 301 here and
lands the visitor on the homepage.

TWO ROUTES THAT ANSWERED 500. Building the new menu meant looking at every URL
it would link, and two of them had never worked:

  /tools/bandwidth-calculator routed to pages#bandwidth_calculator. There is no
  such action and no such template. Every request raised
  AbstractController::ActionNotFound, which in production is a 500.

  /tools/timelaps-interval-calculator routed to pages#timelaps_interval_
  calculator, which likewise does not exist -- the template is there but named
  with hyphens, so nothing could render it, and it has no translations either.

Both routes are removed, so an inbound link now reaches the homepage rather than
an error page. Neither page is linked from the new navigation, and the
unfinished timelapse template is left in the tree: finishing it is a decision
for whoever started it, not something to settle with a routing change.

The upload API keeps its own test. Cameras POST to /snapshots and the gallery
moved to /open-wall; the API did not move at all, and nothing in the relaunch
may shadow it.

That test found a third thing. Snapshot read
Rails.application.credentials.mac.blacklisted directly, so without
config/master.key -- which is what a fresh checkout and the test environment
have -- credentials.mac is nil and every upload raised NoMethodError rather than
simply having nothing to blacklist. Production has the key, so it never showed
there; it made the API impossible to exercise anywhere else. It digs now.

The pages that were replaced are deleted, along with their translations and the
navigation entries that pointed at them: i18n-tasks unused goes from 102 to 72,
below where it started, rather than up. pages/_legacy.scss loses two of its
three rules with the pages they styled -- including the CSS territory gate that
never matched anything.

Two white stripes showed up in the rendered page where one dark band met the
next: the CTA band and the footer both carried a top margin on top of their own
padding. Both dropped.

289 runs, 1276 assertions, 0 failures. i18n-tasks reports no missing
translations across en, ru and zh. rubocop is 734 offences over 114 files
against 742 over 111 on master. Every page was rendered in a browser in all
three languages before this was committed, which is how the footer stylesheet
turned out never to have been imported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hvzXBErEjEGMyRme2K3hi
An audit of the June working tree against what the five preceding changes
actually landed found 20 files unaccounted for. Seventeen were deliberate and
are explained in those changes -- the concern upstream rewrote better, the
locale files for languages no longer served, a Yarn 1 lockfile, a schema dump.
Three were not deliberate. They were missed.

MERCHANDISE. The page advertises a single T-shirt three times: the same image,
the same .shirt_1 keys and the same link, pasted into three cards. It has read
as a catalogue of one product in triplicate on the live site for as long as the
file has existed. One card now, and a test that counts them.

THE OPEN WALL SAYS WHAT IT IS. The wall is in the navigation and the footer
since the cutover, so it now gets visitors who have never seen it and cannot
tell from a grid of stills whether these images were collected or volunteered.
A line under the heading says they were volunteered, and that they are deleted
after two days -- which is PurgeImagesJob::RETENTION, checked rather than
assumed.

THE SURVIVING PAGES HAD NO TESTS. /green_life, /merchandise,
/stages-of-firmware-development, /utilities and the partition and timer
calculators are served by the app and were rendered by no test at all. They are
the pages nobody thinks about, and they render through the layout, navbar and
footer that the relaunch rewrote -- so a mistake in any of those shows up there
first, or not at all. Ten of them are rendered now, checked for their own title
and for translation-missing, and held to the layout wrapper, which pins the
other side of the content_for(:fullwidth) switch against pages that depend on
it.

303 runs, 1390 assertions, 0 failures. i18n-tasks reports no missing
translations and 72 unused, unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hvzXBErEjEGMyRme2K3hi
The existing link test walks whatever the page renders and requires each href to
resolve. That cannot notice a link which stopped being rendered at all -- a
pillar card losing its href, or a whole section dropped in a refactor -- because
a page with fewer links still passes.

This is the one assertion from the June test file that the two replacements did
not carry between them.

304 runs, 1404 assertions, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hvzXBErEjEGMyRme2K3hi
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Restore missed page fixes and legacy coverage

🐞 Bug fix 🧪 Tests ✨ Enhancement 🕐 20-40 Minutes

Grey Divider

AI Description

• Removes duplicate merchandise cards so the single T-shirt appears once.
• Explains Open Wall consent and two-day retention across all supported languages.
• Adds rendering, localization, layout, navigation, and footer coverage for ten legacy pages.
Diagram

graph TD
  U["Site visitor"] --> R["Rails routes"] --> V["Legacy views"] --> L["Shared layout"]
  R --> W["Open Wall"] --> I["Locale copy"]
  T["Integration tests"] --> V
  T --> W
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Derive retention copy from policy
  • ➕ Prevents localized copy from drifting when image retention changes.
  • ➕ Makes the stated two-day policy traceable to PurgeImagesJob::RETENTION.
  • ➖ Couples presentation code to a background-job constant.
  • ➖ Requires localized interpolation and duration formatting across languages.

Recommendation: The current fixes and request-level regression coverage are appropriate for this focused recovery PR. Keep the simple static copy, but add an explicit policy contract test or interpolate the retention value if the retention period is expected to change; the new Open Wall assertion alone does not bind “two days” to PurgeImagesJob::RETENTION.

Files changed (6) +87 / -26

Enhancement (4) +8 / -0
index.html.erbExplain Open Wall participation and retention +5/-0

Explain Open Wall participation and retention

• Adds a visually secondary introduction beneath the Open Wall heading explaining voluntary uploads, automatic deletion, and how camera owners can participate.

app/views/snapshots/index.html.erb

en.ymlAdd English Open Wall introduction +1/-0

Add English Open Wall introduction

• Adds English copy describing voluntary snapshot uploads, two-day retention, and camera-side opt-in.

config/locales/en.yml

ru.ymlAdd Russian Open Wall introduction +1/-0

Add Russian Open Wall introduction

• Adds the Russian translation of the new Open Wall participation and retention explanation.

config/locales/ru.yml

zh.ymlAdd Chinese Open Wall introduction +1/-0

Add Chinese Open Wall introduction

• Adds the Chinese translation of the new Open Wall participation and retention explanation.

config/locales/zh.yml

Bug fix (1) +3 / -26
merchandise.html.erbRemove duplicate T-shirt cards +3/-26

Remove duplicate T-shirt cards

• Deletes two identical product cards so the merchandise page lists its sole T-shirt once. Adds context documenting the prior duplication and regression expectation.

app/views/pages/merchandise.html.erb

Tests (1) +76 / -0
legacy_pages_test.rbAdd legacy page integration coverage +76/-0

Add legacy page integration coverage

• Adds request tests for ten surviving pages, validating successful rendering, localized titles, translation completeness, layout wrappers, navigation, and footer presence. Includes focused regressions for one merchandise product link and the Open Wall introduction.

test/controllers/legacy_pages_test.rb

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Unverified upload consent claim 🐞 Bug ≡ Correctness
Description
The new intro presents every wall image as voluntarily uploaded by an OpenIPC camera, but
SnapshotsController#create accepts unauthenticated uploads and neither requires OpenIPC firmware
nor records/verifies an Open Wall opt-in. Arbitrary clients can therefore publish images that the
page falsely represents as owner-volunteered camera submissions.
Code

app/views/snapshots/index.html.erb[16]

+    <p class="text-body-secondary small" style="max-width: 70ch"><i class="bi bi-camera-video me-1"></i><%= t('.intro_html') %></p>
Evidence
The create action accepts permitted public request data without authentication or opt-in
verification, and firmware is only an optional permitted parameter; the model's validations do not
establish the provenance asserted by the new copy.

app/controllers/snapshots_controller.rb[3-24]
app/controllers/snapshots_controller.rb[92-95]
app/models/snapshot.rb[61-64]
config/locales/en.yml[191-191]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The Open Wall intro asserts consent and OpenIPC-camera provenance that the upload endpoint does not verify. Either add enforceable uploader authentication/opt-in provenance or revise every locale to describe only what the system can guarantee.

## Issue Context
`SnapshotsController#create` is CSRF-exempt, permits firmware as optional metadata, and saves snapshots without authentication or a consent/opt-in field. Model validations only cover the image, MAC address, blacklist, and upload interval.

## Fix Focus Areas
- app/views/snapshots/index.html.erb[12-16]
- config/locales/en.yml[191-191]
- config/locales/ru.yml[193-193]
- config/locales/zh.yml[191-191]
- app/controllers/snapshots_controller.rb[15-24]
- app/models/snapshot.rb[61-64]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Two-day deletion overpromised 🐞 Bug ≡ Correctness
Description
The intro says images are deleted after two days, but the two-day constant is only a cutoff for a
once-nightly purge, so snapshots can remain published for nearly three days even when cron succeeds
and longer when it fails. This makes the new user-facing retention assurance stricter than the
behavior actually implemented.
Code

app/views/snapshots/index.html.erb[R14-16]

+        these were collected or volunteered. Two days is the retention
+        PurgeImagesJob::RETENTION actually enforces. %>
+    <p class="text-body-secondary small" style="max-width: 70ch"><i class="bi bi-camera-video me-1"></i><%= t('.intro_html') %></p>
Evidence
The purge job removes only snapshots older than its two-day threshold when invoked, while the
repository's deployment script documents a single daily invocation at 01:30; therefore two days is
the minimum age at deletion, not the promised deletion time.

app/jobs/purge_images_job.rb[11-18]
deploy/purge-snapshots.sh[3-13]
app/controllers/snapshots_controller.rb[15-18]
config/locales/en.yml[191-191]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The copy promises deletion after two days although cleanup runs only once nightly after records cross the two-day threshold. Revise all translations to communicate the real upper-bound/best-effort behavior, or schedule per-snapshot deletion so the stated retention is enforced.

## Issue Context
`PurgeImagesJob::RETENTION` selects records older than two days at execution time. The deployment cron invokes that job only at 01:30 daily, leaving an additional delay of up to almost 24 hours.

## Fix Focus Areas
- app/views/snapshots/index.html.erb[12-16]
- config/locales/en.yml[191-191]
- config/locales/ru.yml[193-193]
- config/locales/zh.yml[191-191]
- app/jobs/purge_images_job.rb[11-18]
- deploy/purge-snapshots.sh[3-13]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can hide the parts of a finding you never read, like the evidence or the agent prompt

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread app/views/snapshots/index.html.erb
Comment thread app/views/snapshots/index.html.erb
@openipc-ai
openipc-ai changed the base branch from feat/ia-cutover to master August 28, 2026 09:01
…pshot

# Conflicts:
#	test/controllers/relaunch_pages_test.rb
@openipc-ai
openipc-ai merged commit 30f7808 into master Aug 28, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant