Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ The app deliberately begins with a small, verified straight-subdivision catalog.

## Release information

- **Build:** `2026-08-08.3`
- **Build:** `2026-08-08.4`
- **Status:** MVP built and publicly available
- **Live app:** <https://count-it.backwerdrhythmshop.com/>
- **Public app guide:** <https://guides.backwerdrhythmshop.com/count-it/>
Expand Down
31 changes: 27 additions & 4 deletions app/CountItApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import {
type Assignment,
type AssignmentError,
} from "../src/assignment";
import { createPraxisEvidenceResult } from "../src/result";
import { parseSequenceStep, type SequenceStep } from "../src/sequence-step";

type AppMode = "practice" | "challenge";

Expand Down Expand Up @@ -276,6 +278,7 @@ function ChallengeMode({
showReference,
personalBest,
assignment,
sequenceStep,
studentId,
finishedAt,
onStudentIdChange,
Expand All @@ -290,6 +293,7 @@ function ChallengeMode({
showReference: boolean;
personalBest: number;
assignment: Assignment | null;
sequenceStep: SequenceStep | null;
studentId: string;
finishedAt: Date | null;
onStudentIdChange: (value: string) => void;
Expand Down Expand Up @@ -329,17 +333,26 @@ function ChallengeMode({
? "Good work. Review the highlighted subdivisions, then try again."
: "Keep the guide visible and work beat by beat. Accuracy will follow.";
const stamped = finishedAt ?? new Date(0);
/* The evidence, built once and rendered from. Before this the card assembled
its own facts inline, which is why this app had no result object to
migrate — and why the human record and any machine record could have
described different rounds without anything noticing. */
const result = createPraxisEvidenceResult({
session, assignment, sequenceStep, level, scope, finishedAt: stamped,
});
/* Separate from result.attemptReference on purpose: this is the
teacher-facing code with its own published format and its own stated
limits, and whether it generalizes across the three apps is still open.
Making the two identical here would answer that by accident. */
const code = verificationCode({
assignment: assignment?.name ?? "",
studentId,
correct: session.score,
total: session.questions.length,
finishedAt: stamped,
});
const conditions = assignment
? describeAssignment(assignment)
: `${getLevel(level).shortName} · ${scope === "beat" ? "one-beat" : "one-measure"} questions`;
const passed = assignment?.passing != null ? session.score >= assignment.passing : null;
const conditions = result.conditions.stated;
const passed = result.outcome.metGoal;
const summary = [
`Count It — Choose the Count`,
assignment?.name ? `Assignment: ${assignment.name}` : "Practice session",
Expand Down Expand Up @@ -561,6 +574,11 @@ export default function CountItApp() {
// applied after mount so the server-rendered shell and the first client
// render agree; the round it pins takes over immediately afterwards.
const [assignment, setAssignment] = useState<Assignment | null>(null);
/* Which published sequence step a link named. NOT a setting — it records
which assignment this was and never reaches the generator. See
src/sequence-step.ts on why that separation is enforced rather than
assumed. */
const [sequenceStep, setSequenceStep] = useState<SequenceStep | null>(null);
const [linkError, setLinkError] = useState<AssignmentError | null>(null);
const [studentId, setStudentId] = useState("");
const [finishedAt, setFinishedAt] = useState<Date | null>(null);
Expand All @@ -575,6 +593,10 @@ export default function CountItApp() {
// an effect is guarding against cascading renders, and an assignment sets
// several pieces of state at once.
const applyLink = window.setTimeout(() => {
/* Read before the assignment is validated: a link that names a step and
then fails validation is still evidence of which step was attempted,
and refusing the round does not make the marker untrue. */
setSequenceStep(parseSequenceStep(window.location.search));
const result = parseAssignment(window.location.search);
if (!result.ok) {
setLinkError(result.error);
Expand Down Expand Up @@ -805,6 +827,7 @@ export default function CountItApp() {
showReference={showReference}
personalBest={Math.max(personalBests[bestKey] ?? 0, session.status === "complete" ? session.score : 0)}
assignment={assignment}
sequenceStep={sequenceStep}
studentId={studentId}
finishedAt={finishedAt}
onStudentIdChange={setStudentId}
Expand Down
10 changes: 5 additions & 5 deletions public/praxis-capabilities.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
"schemaVersion": "1.0.0",
"appId": "count-it",
"title": "Count It",
"version": "2026-08-08.3",
"version": "2026-08-08.4",
"launchUrl": "https://count-it.backwerdrhythmshop.com/",
"launchUrlStatus": "Live. Cloudflare, custom domain.",
"integrationLevel": 1,
"integrationLevelRationale": "Accepts a configured assignment link and scores objectively, producing a capture-ready result card with a verification code. The result is app-local and never transmitted, so this is Level 1 until the universal result envelope is adopted.",
"integrationLevel": 2,
"integrationLevelRationale": "Accepts a configured assignment link, scores objectively, and emits the universal result envelope (praxis.result.v0_1) — the condition this manifest previously named for Level 2, now met. Transmission is NOT part of the claim and never has been: the envelope is a format, the card renders from it, and nothing leaves the device. Scale Trail claims Level 2 on the same terms.",
"pathway": "rhythm-reading-and-counting",
"supportedActivityTypes": [
"choose-the-count",
Expand Down Expand Up @@ -71,8 +71,8 @@
],
"compatibleSkillDomains": [],
"skillVocabularyStatus": "Not yet reconciled. Candidate ids exist in the Sequence 2 draft but the vocabulary is an owner decision, and an invented id would be a contract no one agreed to. Until it is settled this app emits no skill ids.",
"resultSchemaVersion": "app-local-1",
"resultSchemaStatus": "The result card is app-local: score, accuracy, the assignment's stated conditions, the teacher's pass mark, and a non-cryptographic verification code. It is not the universal result contract and is never transmitted. Migrating it is separate work.",
"resultSchemaVersion": "praxis.result.v0_1",
"resultSchemaStatus": "Adopted 2026-08-08, last of the three apps, after Scale Trail and Mallet Map. This app had no result object at all — the card was assembled inline — so the envelope was built rather than renamed. `skillReferences` is null and says so: the field is nullable across the family precisely so an app with no reconciled vocabulary can decline rather than invent ids. `activity.contentVersion` is null because questions are generated from the conditions and a seed, which already reproduce the round. In measure scope a miss is attributed to every cell in the measure, because the question is answered as a whole — the finest attribution the format allows, not a claim that all four were misread. Emitting the shape is not transmitting it: nothing leaves the device.",
"supportDimensions": "The subdivision guide is a support, not a preference: an assignment pins it, and a gate counts the assignment's policy rather than the learner's own toggle. Question size (one beat or one measure) and rhythm vocabulary are the other two difficulty dimensions. There is no timer anywhere in this app, by design.",
"accessibility": [
"keyboard-operation",
Expand Down
35 changes: 22 additions & 13 deletions src/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { LEVELS, RHYTHM_CELLS } from "./rhythm";
/* The build identifier, single-sourced here so the footer stamp, the manifest,
and the README release line cannot disagree. The repo's release gate checks
the README against this value appearing in app code. */
export const COUNT_IT_BUILD = "2026-08-08.3";
export const COUNT_IT_BUILD = "2026-08-08.4";

export const COUNT_IT_CAPABILITY_MANIFEST = {
schemaVersion: "1.0.0",
Expand All @@ -33,15 +33,18 @@ export const COUNT_IT_CAPABILITY_MANIFEST = {
version: COUNT_IT_BUILD,
launchUrl: "https://count-it.backwerdrhythmshop.com/",
launchUrlStatus: "Live. Cloudflare, custom domain.",
/* Level 1: it scores objectively and builds a result card a student can
submit, but that result is app-local and never transmitted, so it does not
claim Level 2. Raising it means adopting the universal result envelope —
see doc 23, which lists this app's envelope as unreconciled. */
integrationLevel: 1,
/* Level 2 as of 2026-08-08.4. The previous comment here said "raising it
means adopting the universal result envelope" — a criterion written down in
advance, and now met, so the level moves with it. Note what the level does
NOT assert: nothing is transmitted, by this app or by its siblings. Scale
Trail has claimed Level 2 on exactly these terms since before this. */
integrationLevel: 2,
integrationLevelRationale:
"Accepts a configured assignment link and scores objectively, producing a capture-ready " +
"result card with a verification code. The result is app-local and never transmitted, so " +
"this is Level 1 until the universal result envelope is adopted.",
"Accepts a configured assignment link, scores objectively, and emits the universal result " +
"envelope (praxis.result.v0_1) — the condition this manifest previously named for Level 2, " +
"now met. Transmission is NOT part of the claim and never has been: the envelope is a format, " +
"the card renders from it, and nothing leaves the device. Scale Trail claims Level 2 on the " +
"same terms.",
pathway: "rhythm-reading-and-counting",
supportedActivityTypes: ["choose-the-count", "practice-reading"],
/* The URL parameters an assignment link may carry, exactly as the parser
Expand Down Expand Up @@ -83,11 +86,17 @@ export const COUNT_IT_CAPABILITY_MANIFEST = {
"Not yet reconciled. Candidate ids exist in the Sequence 2 draft but the vocabulary is an " +
"owner decision, and an invented id would be a contract no one agreed to. Until it is " +
"settled this app emits no skill ids.",
resultSchemaVersion: "app-local-1",
resultSchemaVersion: "praxis.result.v0_1",
resultSchemaStatus:
"The result card is app-local: score, accuracy, the assignment's stated conditions, the " +
"teacher's pass mark, and a non-cryptographic verification code. It is not the universal " +
"result contract and is never transmitted. Migrating it is separate work.",
"Adopted 2026-08-08, last of the three apps, after Scale Trail and Mallet Map. This app had " +
"no result object at all — the card was assembled inline — so the envelope was built rather " +
"than renamed. `skillReferences` is null and says so: the field is nullable across the family " +
"precisely so an app with no reconciled vocabulary can decline rather than invent ids. " +
"`activity.contentVersion` is null because questions are generated from the conditions and a " +
"seed, which already reproduce the round. In measure scope a miss is attributed to every cell " +
"in the measure, because the question is answered as a whole — the finest attribution the " +
"format allows, not a claim that all four were misread. Emitting the shape is not " +
"transmitting it: nothing leaves the device.",
supportDimensions:
"The subdivision guide is a support, not a preference: an assignment pins it, and a gate " +
"counts the assignment's policy rather than the learner's own toggle. Question size (one " +
Expand Down
Loading
Loading