Compare commits

...
19 Commits
Author SHA1 Message Date
XingfenD d70991b0ad docs: book P15-B as delivered (wrapper 0.3.9, ROADMAP ledger section)
P15-B merged to crearte master as 7f91fa3 (0.27.0, merge-base f823195, thirteen
commits across two fix-forward rounds). P15 is now fully landed.

Three things this entry records that a routine booking would omit.

The whole-branch review blocked the batch - the second time it has done so after
P11 - and its critical finding was in the guard layer rather than the product:
maskSourceComments() compared a two-character slice against the four-character
'<!--', so its HTML-comment branch never ran, producing two false greens and
three false reds and making two already-committed claims false. Product code
needed no change; one added line and one changed comparison closed it.

The second round was executed by the controller rather than the dispatched
subagent, which ran 1h25s and failed with no output, leaving nothing to salvage.
The controller produced one false green of its own on the way - a mutation round
whose anchor failed to match was scored as meeting an expectation that happened
to be an empty list, the same defect the reviewer had caught in itself.

The e2e suite carries a pre-existing flaky leg at roughly one run in three
(core.spec.ts's worker leg: helpers.ts reads an async-postMessage attribute with
a bare getAttribute and no retry). Every file involved has zero diff against the
batch base and all four legs this batch added were green in all three review
runs. Recorded so the next red CI run is not attributed to this release.

ROADMAP also gains a standing ledger section, which the roadmap never had: the
cross-batch items accumulated by P15's two review rounds and the branch review,
each with its measured basis rather than an aspirational note. It includes the
revival of feat/submission-preview - the inline play-test preview for the submit
form and the review page, which the user asked about and which turns out to be
delivered work sitting unmerged in two paired remote branches (crearte a3cb5e3,
crearte-server 6b072d6, 27 tests, zero residue on master), together with the two
hard collisions that make reviving it a real batch rather than a rebase.
2026-10-04 04:14:09 +08:00
XingfenD 51bed55704 docs(spec): close the P15-B branch review's document-side findings
The P15-B branch review returned "needs fixing before merge" on a critical gap
in the guard layer. The code side is with a second fix-forward; this commit
closes the document side, which is the controller's.

F-FINAL-4 - the leg-8 cell carried a one-line regex that the controller wrote
into the spec during re-pin. It was worse than the version in the adjudication
it copied: 4 of 7 attribute forms fail rather than 2, because markdown escaping
turned the alternation bar into a literal bar and `'[^']*'` was mistyped as
`'[^'*]`. The controller had checked that every table row has the same number of
pipes as the header, but never checked that the escaped code was still the code
it came from. The cell now describes the implemented approach (extract every
style attribute value in all three quoting forms, strip quotes, test each for
color:) and deliberately carries no regex literal, with the reason stated: an
escaped bar inside a table cell is a literal character in the regex. Read the
code entity for the pattern.

§6-1 - leg 9 was never re-pinned: the spec still said "length > 500" while the
code says 4000 plus three structural preconditions. Corrected, with the unit
spelled out (source characters 4423, not the built artifact's 7056/7588).

§6-2 - "the only discriminating grid" survived re-pin in four places; the
review measured two legs reddening on a real decision-order defect. Narrowed to
"the only discriminating grid on the child-side direct-visit path" here and in
the plan. The two occurrences in code comments belong to the fix-forward.

§6-3/§6-4/§6-5 - the §1.2 count now states which tree it came from (base 66,
HEAD 67, the extra one being this batch's own note text quoting the figure); the
§4 boundary table lists the e2e spec it had omitted; D-G says "form aligns"
rather than "aligns verbatim", since the script bodies differ by design (the
main app has one more level, 16 token-level differences).

F-FINAL-1 - two claims already committed are corrected rather than deleted. The
§3.1(a) annotation and the §7 risk row both rested on "maskSourceComments()
neutralises the trap comment, so the guard does not depend on the implementer's
wording discipline". That is true for the file in question, but only because the
trap comment happens to use `//`: the function's HTML-comment branch compared a
2-character slice against a 4-character literal and never ran. Merely rewording
a comment reddened up to four legs while the e2e suite stayed green.

§8 gains the census case for discipline 15 (an unlanded census script is why the
48-versus-47 discrepancy is still untraceable), plus disciplines 16 and 17:
every evidence file a report cites must be ls-checked before delivery, and
executable details in a spec must be grepped from the code entity or recomputed,
never hand-written. Discipline 17 records this batch's three instances, one of
them a contrast ratio cited twice by two documents and recomputed by nobody
(3.0269; the actual value is 2.5519, which no grey background produces).

Post-write checks: 25/25 assertions, 11 table blocks with 0 column anomalies,
16 code fences paired, 25 headings with no duplicates, copyable-block cleanliness
scanned across 8 blocks.
2026-10-04 02:53:53 +08:00
XingfenD dd5d0fabf9 docs: book P15-A as delivered (wrapper 0.3.8, ROADMAP rows + third-wave table)
P15-A merged to crearte-server master as e70e99b (0.19.0, merge-base fe810dd,
eight commits on the branch). Both review rounds came back with notes and both
caught the controller's own verification method rather than the refactor: the
gofmt gate in the shared four-gate recipe was exit-code blind, and identifier
counting cannot prove behaviour is unchanged (whole files rather than function
bodies, a hand-picked list read as a universal claim, and no visibility into
control flow at all).

The branch review's most valuable contribution was an evidence layer all four
earlier runs had silently skipped: the five real-database guards the spec names
were SKIPPED everywhere because nobody set TEST_DATABASE_URL, and the ~11s
internal/api timing reported as "including real-database integration" was the
mock path. Running postgres against both trees gave base 194 PASS versus HEAD
199 PASS with identical state multisets - the first runtime proof of unchanged
behaviour on the production database path.

Five of its notes concerned controller artefacts, not code. Four were spec
staleness introduced during re-pin, every one from writing executable details
from memory instead of grepping them out of the code; the fifth was the
fake-rigour form of the vacuous-assertion defect this project keeps finding - a
verification script printing nineteen [OK] lines with no checking logic behind
them while citing an output file that was never archived. Both are now fixed in
37a0a8d and 9061c39, and the lesson is a spec discipline: executable details in
a spec must be grepped from the code entity and pasted, never hand-written.

ROADMAP also gains a correction of my own omission: P15-A and P15-B were
registered in the document index but never added to the third-wave status
table, which still ended at P14.

P15-B is deliberately excluded from this entry. Its branch review returned
"needs fixing before merge" - the second time a branch review has blocked a
batch after P11 - on a critical gap in the guard layer: maskSourceComments()
compares a two-character slice against the four-character '<!--', so its HTML
comment branch is dead code. A second fix-forward is in flight; P15-B books as
0.3.9 when it merges.
2026-10-04 02:42:03 +08:00
XingfenD 9061c39ef3 docs(spec): close the P15-A branch review's A1 and A3 advisories
A1 - the four-gate line said `internal/api ~11.0s 含真库集成`. It does not
include the real-database integration tests: without TEST_DATABASE_URL they
skip silently, and the 11s is the mock path. The wording mattered because it
hid the fact that every run in this batch - implementer, task reviewer,
fix-forward and controller alike - skipped them, including the two
concurrency/TOCTOU guards that §1.6 names as the behavioural evidence for
phase six. Replaced with the measured picture: 4 skips in internal/api, 26
`--- SKIP` lines repo-wide, and the full list of the 11 Test functions gated
on that variable (cmd 2, api 4, repository 2, service 3). Also recorded the
branch review's DB-parity run - base fe810dd 194 PASS/0 FAIL versus HEAD
b15c2a8 199 PASS/0 FAIL, +5 being exactly this batch's new guards, state
multisets identical - which is the first runtime proof of zero behaviour
change on the postgres path; everything before it was static. The
pre-merge checklist now has to include a DB-enabled comparison or the next
batch skips them again.

A3 - fix-evidence/rv-t3-v2-filtered.py prints 6 ORDER VIOLATIONS while the
fix report's prose says both trees have 0. The prose is right and the script
is the artefact: it assigns repeated text lines to destinations with a greedy
pop(0), so identically-named lines land in the wrong bucket. The branch review
redid the check with an unambiguous-unique-text method and got pre=0, post=0.
Annotated the archived script in place (it lives in the gitignored evidence
area, kept for auditability) so nobody cites its violation count as evidence
again, and pointed at the review's §12 reproduction method.

Post-write checks: 20/20 assertions, 12 table blocks with 0 column anomalies,
8 code fences paired, 28 headings with no duplicates, and the annotated script
still passes py_compile.
2026-10-04 02:18:08 +08:00
XingfenD 37a0a8ddd1 docs(spec): close the P15-A branch review's N1-N4 staleness in the A spec
The full-branch review of chore/p15a-approve-phases returned APPROVE with
notes: the merged branch code itself has zero defects, but four places in this
spec disagreed with the code it describes. All four came from the controller
writing executable details from memory during the first re-pin.

N1 - applyApprovalInTx parameter order, three places (D-C row, the §3.1
skeleton call site, the §3.2 phase-six signature). The spec said
`plan approvePlan, submissionID`; the code has always been
`submissionID string, plan approvePlan`. The implementer's order is legal and
better: a scalar identifier before an aggregate matches Go convention.

N2 - §3.2 migration ranges the first re-pin claimed to have fixed but had not:
48-85 -> 48-86 (86 is the closing brace of the coverUpload block) and
87-100 -> 88-101 (87 is a blank line, 88 is `switch sub.Kind`).

N3 - the §1.2 difference-table header: phase four 87-100 -> 88-101, phase six
136-186 -> 136-199 (186 is a mid-branch line inside MetadataChange; 199 is the
closing brace of the switch).

N4 - §T1.4 said the CG2 needle's only hit is `:196`, which is the pre-F9 line
number from dda3fe8; HEAD measures `:201`. The (b) blind-spot note said
"126-200 = 77 lines"; 126-200 is 75 lines - 77 belongs to the §1.1 range
125-201 and was carried over by mistake.

Every corrected value was re-derived from the base tree fe810dd and HEAD
b15c2a8 line by line rather than copied from the review, and the two
derivations agree. A RE-PIN note at §3.2 phase six records the true values and
the lesson, which §8 discipline 11 now states as a rule: executable details in
a spec (regexes, signatures, literals, line ranges) must be grepped out of the
code entity and pasted, never hand-written.

Post-write checks: 20/20 assertions, table column counts unchanged (12 blocks,
0 anomalies), 8 code fences paired, 28 headings with no duplicates.

Closes N1-N4 of crearte-server/.superpowers/sdd-p15a/final-review-report.md.
N5 (the verification artifact's fake rigor) is closed separately in the
gitignored evidence area: verify-fix-v8.py now runs 106 real checks with zero
bare prints, and the v2 output it cites is archived on disk.
2026-10-04 02:00:45 +08:00
XingfenD 02188eb3e0 docs: re-pin both P15 specs after task-level review adjudication
Twenty-four pinned corrections across the two specs and their plans, every one
traced to a measured finding. Both task-level reviewers overturned claims I had
written into the specs, and I reproduced each against the base tree before
accepting it (git show, never the working tree, which already carries the fix).

P15-A spec gains two sections. T1.4 records the reviewer's candidate guard for
phase-independence: the spec called "do not merge phases four and six" the most
important constraint in the batch, yet its only stated mitigation was the
byte-level comparison, which is one-shot evidence — after the report is filed,
nothing reddens when someone merges them. The reviewer built CG1/CG2/CG3 and
verified them both ways: green on the legal tree, red on four distinct merge and
degeneration forms, all assertion-red rather than compile-red, while the three
shipped guards stayed green throughout. T4 records the adjudication of nine
findings, including two that undercut the guard the batch itself added: a
half-finished dir parameter that redirects which files are counted but not which
are read, so a scan pointed elsewhere can satisfy its own precondition while the
169-line function it exists to catch stays invisible; and a span scanner keyed to
line-initial func, which means a 125-line package-level closure passes all four
gates and all three guards. Also pinned: the gofmt gate in the four-gate recipe.
gofmt -l lists unformatted files and still exits zero, so the recipe everyone ran
reported gofmt_exit=0 as evidence of formatting cleanliness. Implementer and I
shared that recipe, which is why the same blind spot was computed twice and
caught by neither.

Phase intervals are corrected to the real base-tree line numbers. The prior text
told the implementer to move lines 212-213 into a helper; 213 is the slog.Info
the same spec requires to stay in the caller, so following it would have swapped
what moves with what stays. Phases six and seven also overlapped, each claiming
the transaction error mapping. The skeleton now passes a pointer where its own
note eighteen lines later demanded a pointer receiver, and the transaction
helper's signature carries submissionID, which removes the batch's dependence on
a cross-repository equality argument for the only acceptance criterion it has.

P15-B spec corrects the artifact criterion I had backwards. I wrote that deleting
a token should change the var(--color-*) count; that count measures usage, the
token had zero usage across seven utility suffixes, and it stayed at 75. Had it
moved, that would have meant something referenced the token, contradicting the
dead-token premise. Definition-side and usage-side are separate measures and the
spec now says so.

Leg five is re-pinned as critical. It was the sole enforcement point for the
non-reactive injection decision, and it only pinned literal form: two
type-legal variants that establish the dependency elsewhere in the computed body
pass all ten legs with vue-tsc clean, and the reviewer proved by effectScope
evaluation counting that both really do make the iframe src flip on a theme
change, reloading a running game. The narrowing was introduced by the
implementer's own fix, to avoid a trap comment that spells out the forbidden
literal; but comment masking already neutralizes that comment, so the narrowing
was unnecessary and the second line of defense created the gap. Same shape as
the P14 final review finding a hole in the first round's fix.

Also pinned: the rejected deviation five, with the corrected root cause (all four
freeze attempts used page.route, which does not intercept service worker
registration, worker-issued requests, or navigations synthesized by respondWith;
context.route holds the loading screen past 25 seconds); the missing
dark-system-times-light-hash grid that one mutation slipped past all ten source
legs and all five e2e legs simultaneously; the fourth tautology class the spec's
three warnings omitted, where emptying a loop's driving collection makes it
vacuously true; legs seven and eight, which respectively substring-searched a
hex that occurs three times in the file and matched only double-quoted style
attributes; the copyable code block's comment, which now avoids the three
literals that would false-redden a correct implementation, with the annotation
moved outside the block; and three new discipline entries covering untested
method scope, measurement units, and retaining one-off verification scripts.

Every correction was checked by a script asserting both directions — the pinned
text present and the superseded text gone — plus table column integrity, fence
pairing, and code-block cleanliness. Three earlier attempts at this re-pin failed
on my own errors and were fixed before commit; the working tree was never left in
a half-edited state.
2026-10-03 19:48:48 +08:00
XingfenD 82f0a6d360 docs: add P15-A and P15-B specs and plans, register both in ROADMAP
Two batches, one per repo, so they can run in parallel under the one-writer-per-repo
rule: P15-A refactors crearte-server's Approve function, P15-B fixes a dark-mode gap
in crearte's game loading screen plus two guard items.

P15-A splits a 169-line function whose seven phases are mapped here with real line
numbers — the base-tree mapping logged during P14 is void, since that batch moved the
function into moderation.go. The spec's central constraint is that phases four and six
look alike but must not be merged: four is an optimistic pre-check that runs unlocked
before any object copy, six is a pessimistic re-check under a row lock after the copies
have happened. They differ in four concrete ways (kind coverage, the NewVersion runtime
and bundle checks, whether entities are created, and the error wrapping), so the
apparent duplication is deliberate TOCTOU defence rather than removable redundancy.
A measurement also overturned the survey's claim about helper shape: both an unexported
method and a package-level function leave all seven P14 assertions green, because
IsExported filtering and NumMethod's exported-only view put neither in scope. The
choice is therefore about needing receiver fields, not about the guard.

P15-B fixes a gap P13 left: its token work covered only the src/index.html entry and
missed the second Vite entry, bootstrap, so the game loading screen hardcodes light
values and dark-theme users see a white flash on every virtual game launch. The fix
rides the hash channel bootstrap already parses rather than inventing a postMessage
protocol, which would be async and so repaint light first — the very flash being
removed. The spec records the trap that makes this easy to get wrong: GameHost builds
targets in a computed, so injecting the reactive theme ref would make a theme switch
recompute the iframe src and reload a game in progress; the non-reactive snapshot is
required, and a guard leg plus a mutation exist solely to hold that line. It also
records two pre-existing contrast violations found on the way, where inline style
attributes override conforming stylesheet values with lower-contrast ones, and why
noHardcodedColor cannot be extended to cover this file: it only collects .vue and
blanks out style blocks, so widening its roots would scan nothing while looking
like coverage.

Every line number and quoted signature in both specs was checked against source
before commit; two claims the survey had asserted from reasoning were measured
instead, and one of them was wrong.
2026-10-03 13:28:33 +08:00
XingfenD 163b0d703a docs: record P14 as delivered (wrapper 0.3.7, ROADMAP row + backlog)
The ContentService split landed in crearte-server 0.18.0 as merge fe810dd off
merge-base dbf7fe5. The third-wave table gains the P14 row and the doc index
marks it executed against the merge commit, per the P12/P13 convention of citing
the merge rather than the accounting commit.

The row records the batch's actual result, which is not the split but the two
review rounds that each caught the controller's own error: the task review found
that the guard purpose the spec states was covered by no assertion at all and
that the spec's own positive control had a bypass; the branch review then found a
hole in the first round's fix, where the source-scan pattern required a named
receiver and so let an unnamed-receiver shadow pass all seven assertions while
the shadow was effective. It also overturned two universal claims I had already
committed to the spec, both reproduced before acting on them.

Backlog gains the 169-line Approve function with its line range relocated after
the split (the base-tree mapping is void), plus six smaller items. It also logs a
dark-mode gap found while surveying the next batch: P13's token work covered only
the src/index.html entry and missed the second Vite entry, bootstrap, whose
loading screen hardcodes light values, so dark-theme users see a white flash on
every virtual game launch.
2026-10-03 12:37:10 +08:00
XingfenD 33c2d8d4b8 docs: re-pin the P14 spec after branch-review adjudication
The branch review approved with notes but overturned two universal claims I had
already committed to the spec. Both overturns were independently reproduced
before acting on them, and both were correct.

FR-1 (important): assertion 5's source scan required a NAMED receiver, but Go
permits omitting an unused one, and a shadowing body is exactly the case that
often needs none. Under func (*ContentService) CoverURL(...) the old pattern
matched nothing, so build, vet and all seven assertions stayed green while the
shadow was effective. The regex literal in §5-T1.5 is corrected to the optional
group now in the code, and the claim that assertion 5 is the only mechanical way
to catch shadowing is scoped: it was false before the fix, and after it the
assertion's reach is wider than the original text said, because NumMethod()
exposes only exported names — so an unexported root method is also caught by the
source scan alone.

FR-5: my adjudication of F4 said the two layers 'cover completely'. That is a
universal claim and an orphan private helper on the wrong line is its
counterexample (assertion 2 filters on IsExported, and Go does not report unused
methods). The wording is narrowed to what the layers actually cover, with the
reason the verdict still holds: the third layer can only ever produce dead code.

Both are recorded as instances of the same defect I keep committing — stating a
conclusion before establishing its range. §8 rule 5 already required universal
claims to have universal-range evidence; these were two places I did not follow
my own rule.

The lesson is written into the spec rather than only the ledger: when claiming a
guard is the only thing that catches a failure mode, enumerate the forms first
(named/unnamed receiver, value/pointer, exported/unexported), or the claim
becomes the next reviewer's counterexample.
2026-10-03 12:26:55 +08:00
XingfenD 11fa1e2d26 docs: re-pin the P14 spec after task-review adjudication
The task-level review rated the split PASS with notes and found that the
architecture guard did not cover one of the three purposes spec D-J states for
it. Six places are corrected here before the branch review reads the spec, so
the reviewer works against a frozen target rather than a moving one.

- D-J is amended from three assertions to seven. The original three each have
  teeth (verified by mutation) but together they missed an entire dimension:
  nothing enumerated the composition root's own method set, so adding a method
  there left all three assertions PASS, go build/vet clean, and the full
  11-package suite green. The god object could regrow silently.
- The mutation list gains d through h, and mutation (a) is annotated with the
  bypass the review found in my own positive control: (a) turns red because it
  REMOVES CoverURL from CatalogService, not because anything detects the extra
  root method. Rewritten as a copy that keeps the original — the shadowing form
  (e) — mutation (a)'s design would have passed green.
- Two fixes the review proposed are recorded as rejected, with the spike
  measurements, so they are not retried: asserting NumMethod()==0 on the value
  type is unsatisfiable because embedding *T puts its methods in both method
  sets (the legal tree already reports 22 — vacuously false, the mirror image of
  P13's vacuously true assertion), and Method.Func identity cannot detect
  shadowing because promoted methods are forwarding wrappers that already differ
  on the legal tree (5153408 vs 5148192 unshadowed, 5148288 vs 5148192
  shadowed).
- Line counts are pinned to the wc -l convention, verified against the same
  convention used for the 856 baseline and auth.go's 234. The metrics table gains
  a measured column: content.go 82, largest of the six files 298 (moderation.go,
  not submission.go as the spec predicted).
- D-D records that ErrSubmissionConflict is also used by AccountService at
  account.go:136, outside the submission and moderation lines, which makes the
  SHARED ruling necessary rather than merely correct.
- The risk table gains two Route C properties that were previously only in
  controller notes: the root has no named fields so s.objects is an ambiguous
  selector (a compile-time barrier earlier than the guard, and the reason the
  guard is the ONLY barrier against adding a concrete field), and go vet stays
  silent on a root method shadowing a promoted one (vet_exit=0 measured), which
  is why assertion 5 cannot be replaced by a reflect-based check.
2026-10-03 10:36:58 +08:00
XingfenD 9965fd73ee docs: add the collision constraint to the GameHost a11y backlog item
The P13 final review logged GameHost's degraded-link badge (paper on accent =
3.2590 in light, below the 4.5 required for an 11px bold label) to the
accessibility polish batch. The obvious one-line fix — switching the badge to
bg-accent-ink — was falsified by measurement before it could be logged as if it
worked: light paper on accent-ink does pass at 4.8700, but accent and
accent-ink are only 1.4943 apart in light, and the badge shares the showcase
title bar with the phase status dot whose 'load failed' state already uses
bg-accent-ink. Recolouring the badge would collapse 'degraded to external link'
and 'load failed' into one visual signal, trading a contrast defect for a
semantic one.

Recorded as a design decision rather than a one-line change, so the next batch
does not walk into it. This extends the P12 lesson that a parked backlog item
must be falsified or confirmed before being implemented: what needs falsifying
is not only whether the item is worth doing, but whether the obvious way to do
it works.
2026-10-03 08:14:52 +08:00
XingfenD ba1fe3ecb0 docs: P14 design + plan for splitting the ContentService god object
Registers the next batch before implementation starts, so the design is
versioned and reviewable rather than living only on disk.

Scope: crearte-server only. content.go is 856 lines / 30 functions, the sole
outlier in internal/service (next largest production file auth.go is 234 lines;
content.go alone is 22% of the directory) and carries five unrelated
responsibility lines in one struct whose five fields are all shared repository
dependencies with no mutable internal state.

Three survey findings make the split low-risk rather than aspirational:
- the seven cross-line calls all target package-level helper functions, with
  zero method-to-method cross-line calls, so splitting creates no cross-service
  callbacks and no import cycle;
- each of the five handlers uses exactly one line's methods with zero overlap,
  so each dependency face narrows from 22 methods to its own 2-6 with no adapter;
- 11 of the 19 test construction sites only construct and never call methods,
  and a Go embedding spike (H1-H4, run in golang:1.24-alpine, vet clean)
  confirmed the promoted method set satisfies consumer-defined narrow
  interfaces — so the composite root keeps every construction site and all five
  serve.go wirings unchanged while handlers still narrow.

The survey also found ContentService.now is a dead field: zero s.now references
in the file, no test seam injecting it, while the sibling services that share
the pattern do use theirs (auth.go reads s.now(), cleanup.go has SetNow). It is
removed as part of the split rather than being assigned a line.

Two risks were falsified by measurement before designing: no code inside the
package reads ContentService's private fields (AccountService holds
repository.ContentStore, not *ContentService, so it is untouched), and the type
is never interface-ised, type-asserted, or used as a method value.

Deliberately out of scope: the 170-line Approve function (its seven phases are
mapped and logged for a later batch — one concern per batch), memory_content.go
(814 lines but a test double with zero non-test references), and handler
error-mapping dedup (92 WriteError sites but only 2 errors.Is checks, so the
duplication does not justify itself).

Verification plan: four gates in the dockerized toolchain plus structural
metrics (content.go under 120 lines, largest of the six files under 300, zero
service.ContentService references left in handlers, zero existing test files
modified) and three mutations the new architecture guard must fail on.
2026-10-03 08:02:53 +08:00
XingfenD 14d029e61b docs: register P13 dark mode (crearte 0.26.0) + server micro-batch (0.17.2)
Brings the P13 batch into this wrapper's version control: spec and plan enter
the repo, ROADMAP gains the P13 row and its document-index entry, and the four
stale 'C dark mode' backlog mentions carried since P9/P10/P9-B/P12 are marked
cleared in place — following the P12 precedent of annotating the historical row
rather than rewriting it, since those entries were true when written.

Wrapper CHANGELOG 0.3.6 records the crearte-only dark-mode delivery
(983e7c4, merge-base e59a171), the parallel server micro-batch delivered during
the survey phase while the implementer owned crearte exclusively (dbf7fe5,
0.17.2: a real uploads.go error-fallthrough defect with zero prior coverage,
plus a decision-record comment on the bundle-key route after grep overturned the
initial suspicion of a hole), and three lessons:

- max(a,b) >= k is a vacuous-assertion hot zone: when the two sides are
  complementary the max has a non-trivial lower bound (here sqrt(16.50) =
  4.0621), so any threshold below it can never fail. The controller's own first
  correction to the scrim guard shipped exactly that, and the same
  one-directional verification recurred in the alpha fallback. Fixing a guard
  now requires proving both no false-red on legal values and no false-green
  under mutation.
- Tailwind v4 scans every source file including test files, so a class-name
  literal in a test comment burns a dead utility into the artifact.
- A universal claim needs a universal grep: 'accent is the only background use'
  was false because both the spike-0 grep and the new guard covered app/ while
  runtime/ sits beside it. That blind spot cost a false spec fact and hid a real
  pre-existing WCAG violation (paper on accent = 3.2590 in light, since P9-B).

ROADMAP's P13 row cites merge commit 983e7c4 per the P12 convention, with the
bookkeeping commits named separately so the reference cannot be mistaken for
them.
2026-10-03 07:30:12 +08:00
XingfenD b9e59aee83 docs: P12 narrow-viewport batch delivered (crearte 0.25.0 / server 0.17.1 / deploy 0.7.1)
Register the P12 spec and plan in the ROADMAP doc index, add the P12 batch row,
and update two backlog lines that this batch's measurements settled:

- The 'mobile header / hamburger menu' backlog entry carried since P9-B is
  falsified and removed: nav content width is only 74-158px and measures zero
  horizontal overflow at 320/360/375/412/768/1280px. Building it would have
  shipped a hamburger menu nobody needs.
- Nav links wrapping per-character at phone widths stays parked pending a
  design decision, not a CSS tweak: it is cosmetic only (no overflow, no
  clipping, no lost function), whitespace-nowrap costs +11px at 320px, and all
  seven gap-reduction variants measured fail at 320px + long name because
  logo 77px + nowrap nav 138-158px + truncated name 96px do not fit. It
  competes for the same pixels as the header fix.
- The P11 polish backlog is partially retired: three of P9-B's four items plus
  P11-N5/N6 are closed by this batch; the remaining nine P11 minor notes are
  documentation-only with no actionable change.

Merged and pushed: crearte e59a171 (0.25.0), crearte-server d56c593 (0.17.1),
crearte-deploy 529a988 (0.7.1). Three task-level reviews plus a whole-branch
final review verdict APPROVE with notes, zero critical and zero important; all
notes adjudicated before merge. The final reviewer independently reproduced
three mutations rather than accepting the controller's claims.
2026-10-02 21:16:43 +08:00
XingfenD 708f95eb3d docs: P11 server-hardening delivered (server 0.17.0 / deploy 0.7.0 / crearte 0.24.1)
ROADMAP P11 row -> 完成 with the D-A->D-A' re-pin narrative; doc index updated;
CHANGELOG 0.3.4 (Done section, bilingual) recording the endpoint-verification
catch: the original subnet-trust design was a rate-limit bypass under compose's
docker SNAT, corrected to an empty TRUSTED_PROXIES default. Register the P11
spec + plan in the doc index.
2026-10-02 03:54:41 +08:00
XingfenD 0e63dc2448 docs: P9-B UX batch-2 bookkeeping — ROADMAP row + doc index + CHANGELOG 0.3.3 (crearte 0.24.0, merged 338acbf) 2026-10-01 23:15:05 +08:00
XingfenD 015c2b9696 docs: P9-B spec §1/D-F th count corrected to element-level 24 (19 was line-count; adopted from T3 review note 3) 2026-10-01 22:49:51 +08:00
XingfenD 35b50539c6 docs: P9-B spec D-I re-pinned — persistent sr-only announcer (live region must pre-exist its text; per-toast role=status would mute the first message) 2026-10-01 21:43:31 +08:00
XingfenD 0a7eba2682 docs: P9-B spec + implementation plan (a11y/keyboard batch — WCAG contrast tokens, toast live-region restructure, skip-link, SPA focus management, table column-header scope, star accessible names) 2026-10-01 21:26:32 +08:00
16 changed files with 3547 additions and 2 deletions
+103
View File
File diff suppressed because one or more lines are too long
+39 -2
View File
File diff suppressed because one or more lines are too long
+115
View File
@@ -0,0 +1,115 @@
# P9-B UX 第二批(可访问与键盘效率)· 实现计划
spec:`docs/specs/2026-10-01-p9b-a11y-keyboard-design.md`(决策 D-A…D-K 以其为准;本计划与 spec 冲突时 spec 赢)。
仓库:**仅 crearte**(`crearte-monorepo/crearte`),分支 `feat/p9b-a11y-keyboard`。server/deploy 零改动。
npm 根:`crearte/src`(package.json 在此)。命令一律 `cd crearte/src` 后跑。
## 全局约束(逐字,进每个任务简报)
1. 零新依赖:不得改 `package.json` dependencies/devDependencies;`version` 字段恒 `0.1.0` 不动。
2. 禁触文件:`e2e/landing.spec.ts`、`e2e/noauth.spec.ts`、`e2e/author-page.noauth.spec.ts`、`e2e/merge-repo.spec.ts`、`e2e/ux.spec.ts`、`e2e/submit-flow.spec.ts`、`e2e/admin-flow.spec.ts`、`e2e/helpers.ts`、`playwright.config.ts`、`playwright.noauth.config.ts`、`vitest.config.ts`、`vite.config.ts`、`vite.config.test.ts`、`scripts/`、`runtime/`(本批完全不动,含 `GameHost.vue`)。
3. 样式语言:新粗野主义既有 tokens(`border-2 border-ink`、`shadow-hard*`、`bg-paper/surface/ink/highlight/accent/accent-ink/success`、`btn-ink/btn-surface/lift`、`font-mono text-[0.6875rem]`、`sr-only`)。注释中文、标识符英文(仓内惯例)。`app/lib/no-gradient.test.ts` 守卫(禁渐变/禁圆角工具类/禁非零 border-radius)必须继续绿——本批不得引入 `rounded*`、`gradient`、非零 radius。
4. TDD:先写/改 vitest 跑红,再实现跑绿;每个任务在自己报告里贴 RED/GREEN 的命令与输出摘要。
5. 每任务独立 commit(前缀 `feat(a11y):` 或 `fix(a11y):` + 任务号);**CHANGELOG 不由任务写**——控制者在波次末统一补 0.24.0。
6. 验收命令(每任务收尾自跑并贴输出摘要):`npm test -- <本任务改动面的测试文件>` + `npm run typecheck`。全量 `npm test` / `npm run build` / `npm run e2e` / `npm run e2e:noauth` 由控制者在波次末跑。
7. 分支纪律:commit 前 `git branch --show-current` 必须是 `feat/p9b-a11y-keyboard`;不 merge、不 push、不碰 wrapper 仓(ROADMAP/spec/plan 记账归控制者)。
8. 基线不回退:vitest **601** 全绿、主 e2e **77+1skip**、noauth **4**。既有测试断言只在 spec/本计划明确要求时才可改(本批有两处:`ToastHost.test.ts` 的 `bg-accent`→`bg-accent-ink` 与容器 role 断言反转)。
9. 精确使用 spec 给定值:skip-link 文案 `跳到主内容`、`href="#main"`、`main` 的 `id="main" tabindex="-1"`;操作列表头 sr-only 文本 `操作`;星标 `aria-label` 模板 `评 ${i} 星`、分组 `aria-label` 模板(未评 `评分`,已评 `评分:${score} 星`);`--color-success: #1f7a4d`;error toast 类 `bg-accent-ink text-paper`;toast 文本节点 `<p role="status" aria-live="polite" class="min-w-0 flex-1">`。
10. 本批**不做**移动端汉堡菜单(spec D-K:实测 360px/320px 视口下 `documentElement.scrollWidth` 恰等于视口宽,无溢出);**不做** `<td>`→`<th scope="row">` 行头重构(spec D-F);**不做**暗色模式(C 包另行立项)。
---
### Task 1: 色彩对比达标 + toast 实时区重构(D-A / D-B / D-C / D-I)
改动文件:`app/styles/main.css`、`app/components/ToastHost.vue`、新 `app/lib/contrast.test.ts`、扩 `app/components/ToastHost.test.ts`。
**TDD 顺序**:
1. 先写 `app/lib/contrast.test.ts`(RED:`success` 当前 `#2fa46a`,`paper on success` 只有 3.16,断言 ≥4.5 必红)。逐字实现 spec §3.1 的 `channel`/`luminance`/`contrast` 三个函数(放测试文件内即可,不必新建 lib 模块——它是守卫而非生产代码)。令牌解析:从 `app/styles/main.css` 读文本,正则 `/--color-([\w-]+):\s*(#[0-9a-fA-F]{6})/g` 建 map;先断言 map 至少含 `paper/surface/ink/ink-soft/ink-faint/accent/accent-ink/highlight/success` 九个键(解析失败要红得明确,不要静默跳过配对断言)。
2. 断言 ≥4.5 的配对(`fg on bg`,逐字用 spec §3.1 清单):`paper on success`、`paper on accent-ink`、`ink on highlight`、`ink on accent`、`ink-soft on paper`、`ink-faint on paper`、`ink-soft on surface`、`ink-faint on surface`、`paper on ink`、`accent-ink on paper`。断言 ≥3 的:`accent on paper`(焦点环,WCAG 1.4.11 非文本)。断言时给出实测值以便失败可读(例如 `expect(contrast(paper, success), 'paper on success').toBeGreaterThanOrEqual(4.5)`)。
3. 改 `--color-success: #1f7a4d` → 跑 GREEN(预期 `paper on success` 4.76)。
4. 改 `ToastHost.test.ts`:① 容器断言反转为**不含** `role`/`aria-live`(`expect(host.attributes('role')).toBeUndefined()`、`aria-live` 同);② 新断言每条 toast 内 `p[role=status][aria-live=polite]` 存在且 `text()` 等于消息;③ 新断言关闭按钮是实时区**兄弟**:`button.element.parentElement === p.element.parentElement` 且 `p.element.contains(button.element) === false`;④ kind 配色断言里 `error` 的 `bg-accent` 改 `bg-accent-ink`(`success`/`info` 两行不动);⑤ 既有「点关闭 → store 少一条」用例保留。先跑 RED。
5. 按 spec §3.5 逐字重写 `ToastHost.vue` 模板 + 按 spec §3.1 改 `KIND_CLASS` 的 `error` 行 → GREEN。`<script setup>` 里除 `KIND_CLASS` 外一字不动(`PhX` import、`useToast` 解构都保留)。
**陷阱**:容器去掉 `role="status"` 后,`data-testid="toast-host"` 与全部定位类(`pointer-events-none fixed bottom-4 right-4 z-[70] flex w-72 flex-col gap-2`)必须原样保留——`ux.spec.ts`(禁触)与既有单测靠 `data-testid` 定位。`useToast.ts` 一字不动。
commit:`feat(a11y): P9B-T1 WCAG contrast tokens + toast live-region restructure (D-A/D-B/D-C/D-I)`
---
### Task 2: skip-link + SPA 导航后焦点管理(D-D / D-E)
改动文件:`app/App.vue`、`app/router/index.ts`、扩 `app/router/index.test.ts`。
**TDD 顺序**:
1. 先扩 `app/router/index.test.ts`,新增 describe「SPA 导航后焦点(D-E)」,RED:
- `beforeEach` 里往 `document.body` 注入 `<main id="main" tabindex="-1"></main>`(happy-dom 需要元素在文档里 `focus()` 才生效——P10 T5 已验证过这条),`afterEach` 移除它并 `document.activeElement?.blur?.()`,避免污染同文件既有标题用例。
- 用例①:`makeRouter()` → `push('/')` → `push('/games')` → 断言 `document.activeElement?.id === 'main'`。
- 用例②(同路径改 query 不抢焦点):`push('/games')` 后把焦点挪到别的元素(注入 `<button id="probe">` 并 focus),再 `push('/games?tag=数字')` → 断言 `document.activeElement?.id === 'probe'`(**不是** main)。
- 用例③(`#main` 缺失时静默):移除注入的 main → `push('/docs')` 不抛错(`await expect(router.push('/docs')).resolves.not.toThrow()` 或包 try 后断言 `document.title` 仍被设置,证明 afterEach 其余职责照常执行)。
2. 按 spec §3.2 逐字加 `focusMain()` 导出 + 改 `attachTitleHook` 回调为 `(to, from)` → GREEN。既有 `attachTitleHook` 的 title/description 两行与 `r.afterEach` 结构不动,只在末尾加 `if (to.path !== from.path) focusMain()`。
3. 按 spec §3.2 逐字改 `App.vue` 模板(skip-link 作根 div 首子元素、`<main id="main" tabindex="-1">`)。`<script setup>` 一字不动。
**陷阱**:`router/index.ts` 的 `routes`/`scrollBehavior`/`beforeEach`/`attachTitleHook(router)` 末行全部不动;`pageTitle.test.ts` 与 `router/index.test.ts` 既有用例(标题基线、description 复位、game 复合 id)必须继续绿。skip-link 用 Tailwind `sr-only focus:not-sr-only` 组合,**不要**在 `main.css` 新增 `.skip-link` 规则(spec D-D)。
commit:`feat(a11y): P9B-T2 skip-link + focus main on SPA path navigation (D-D/D-E)`
---
### Task 3: 管理端表格列头语义 + 守卫(D-F / D-G)
改动文件:`app/views/AdminView.vue`、`app/views/AdminUsersView.vue`、`app/views/AdminAuditView.vue`、新 `app/lib/tableScope.test.ts`。
**TDD 顺序**:
1. 先写 `app/lib/tableScope.test.ts`(RED:当前全仓 19 个 `<th>` 零 scope)。仿 `app/lib/no-gradient.test.ts` 的结构逐字复用其 `walk`/`candidates` 思路:递归扫 `app/**/*.vue`(跳过 `*.test.ts`),对每个文件把内容按 `<th` 出现处切分,取从 `<th` 起到其后第一个 `>` 止的片段作为「起始标签」,断言其中含 `scope=`(这样多行书写的 `<th\n class=…\n>` 也能正确判定)。违规收集为 `相对路径:行号: 行内容` 并 `expect(violations).toEqual([])`。
2. 给三个视图的每个 `<th>` 加 `scope="col"`(共 19 个),类名/文本/顺序一字不动。两处空表头(`AdminView.vue` 队列表 `<th class="px-3 py-2" />`、`AdminUsersView.vue` `<th class="px-3 py-2" />`)改为 spec §3.3 的 `<th scope="col" class="px-3 py-2"><span class="sr-only">操作</span></th>`。→ GREEN。
**陷阱**:`AdminView.vue` 有 3 张表(队列/已通过/作品管理),`AdminUsersView.vue` 1 张、`AdminAuditView.vue` 1 张,全部要覆盖;不得改 `<td>`、`<thead>`、`<tbody>`、`data-testid`、任何列文本。`AdminUsersView.test.ts` / `AdminAuditView.test.ts` 既有用例(`listAdminUsers` 调用形状、`user-row-*`、`audit-status-*`)必须继续绿。`AdminView.vue` 无测试文件,不必新建(守卫测试已覆盖其表格语义)。
commit:`feat(a11y): P9B-T3 table column-header scope on admin views + guard test (D-F/D-G)`
---
### Task 4: 五星评分可访问名(D-H)
改动文件:`app/components/GameReactions.vue`、扩 `app/components/GameReactions.test.ts`。
**TDD 顺序**:
1. 先扩 `GameReactions.test.ts`,RED:
- star-1..5 各自 `attributes('aria-label')` 为 `评 1 星`…`评 5 星`。
- 分组容器(星标外层 `span`)`role="group"`;`rated: false` 的 VIEW 下 `aria-label === '评分'`;构造 `fetchMine` 返回 `ratings: { '<user>/<slug>': 4 }`(沿用该文件既有的 mock 范式,见其 `h.fetchMine.mockResolvedValue` 用例)使 `rated === true`,断言 `aria-label === '评分:4 星'`。
- 每颗星内层字形 span 有 `aria-hidden="true"`。
- **既有用例全部保留且必须继续绿**:`.text()` 为 `★`/`☆` 的断言(`aria-label` 不改文本内容,故兼容)、`4.5 · 2 人评分 · 3 收藏`、`fav-btn` 的 `aria-pressed`、点 ♥ 调 `setFavorite`、点星调 `setRating`/`unrate`。
2. 按 spec §3.4 逐字改 `GameReactions.vue` 的星标分组 → GREEN。`data-testid`、`:disabled="busy"`、类名、`@click="pickStar(i)"`、`litStars` 逻辑、撤评语义(点当前分撤评)全部不动。
**陷阱**:`role="group"` 加在既有的 `<span class="inline-flex items-center">` 上,不要新增包裹层(会改排版)。`rated`/`score` 是该组件既有 ref(`GameReactions.vue:15-16`),直接用,不要新造状态。
commit:`feat(a11y): P9B-T4 accessible names for the five-star rating control (D-H)`
---
### Task 5(控制者本人执行,不派发): e2e a11y.spec.ts(D-J)
新增 `src/e2e/a11y.spec.ts` 三条用例(spec §3.6 逐字),既有 e2e 文件与 `helpers.ts` 一字不动。
**已核实的运行期前提**(避免重蹈 P10 T6 的夹具错误):
- e2e 构建(`npm run build:e2e`)注入 `VITE_API_BASE_URL=http://localhost:4173` → `authEnabled` 与 `reactionsEnabled` 均为 true,故 `seedSession` 可用、`GameReactions` 会渲染。
- `/admin/users` 的表格**需接口有数据才渲染 `<tbody>` 行**,但 `<thead>` 的 `columnheader` 在 `StatePanel` 插槽内、加载完成后即存在;`e2e/admin-flow.spec.ts` 的 `installAdminApi` **未导出**,故本用例自带最小 `page.route` mock:`${API}/api/admin/users**` 返回 `{ users: [<一个 AdminUser>], total: 1 }`,`AdminUser` 形状逐字为 `{ id, email, username, display_name, role, created_at }`(`app/data/types.ts:102-109`)。`toHaveCount(6)` 依赖 6 个 `<th>`(用户名/邮箱/显示名/角色/注册时间/操作)。
- 星标用例走 `fixture/2048`:其源夹具 `runtime` 为 `external`,但本用例只断言 `star-3` 的可访问名,**不调 `openGame`**(不等 iframe ready),故 external 无碍;`GameReactions` 由 `v-if="game"` 门控,作品页加载完成即渲染。
- skip-link 用例:`page.keyboard.press('Tab')` 的首站必须是 skip-link——它是根 div 首子元素,页头在其后。`Enter` 后原生锚点跳转把焦点交给 `#main`(`tabindex="-1"`)。
波次末由控制者跑五腿:`npm test` / `npm run typecheck` / `npm run build` / `npm run e2e` / `npm run e2e:noauth`。
commit:`test(e2e): P9B-T5 a11y spec — skip-link keyboard path, admin column headers, star accessible names (D-J)`
---
## 执行顺序与并行性
T1/T2/T3/T4 改动文件两两不相交(T1: main.css+ToastHost+contrast.test;T2: App.vue+router;T3: 三 admin 视图+tableScope.test;T4: GameReactions)。但四者共用同一工作树与同一分支索引,并行 commit 会争 `index.lock` 并交叉 `git add`,故**串行派发**,每任务完成并过任务级审查后再派下一个。T5 由控制者在四者之后自己写并跑五腿。
每任务审查重点(任务级审查者的核查项):禁触清单零触碰、`no-gradient` 守卫仍绿、既有测试断言除授权两处外未被改动、spec 逐字代码与文案的偏差、以及各任务的 D 项是否真达成(不是「看起来改了」)。
@@ -0,0 +1,76 @@
# P11 服务端安全硬化批 — 实现计划
日期:2026-10-02 | spec:`docs/specs/2026-10-02-p11-server-hardening-design.md`(权威,冲突时 spec 赢)
## 全局约束(逐字进每个任务简报)
1. **零新依赖**:`crearte-server/src/go.mod` 与 `go.sum` 不得出现新 require;前端 `package.json` 不动。
2. **Go 工具链(硬性)**:宿主 Go 是 1.18,**不可用**。所有 `go build/vet/test` 必须在容器里跑:
```sh
cd <repo>/crearte-server/src && docker run --rm \
-v "$PWD":/src -w /src \
-v crearte_gomod:/go/pkg/mod \
-e GOCACHE=/gocache -v crearte_gocache:/gocache \
-e GOPROXY=https://goproxy.cn,direct -e GOSUMDB=sum.golang.google.cn \
golang:1.24-alpine go test ./internal/api/ -run 'X' -count=1
```
冷构建 1–3 分钟属正常,用 `exec` 后台 + 耐心轮询,**不要**因为没立刻出结果就重跑。`proxy.golang.org` 在本机不可达,漏掉 `GOPROXY` 会挂死。
3. **禁触文件**:`crearte-server` 的 `internal/bundle/**`、`internal/repository/migrations/**`、`internal/storage/**`、`cmd/**`(除 spec 明列);`crearte` 的 `src/app/**`、`src/e2e/**`、`vite.config.ts`、`package.json`、各 config;`crearte-deploy` 的 `scripts/**`、`.github/**`、`backups/**`。
4. **compose 数据身份红线**:不得重命名或增删任何卷(`pgdata-*`、`bundle-keys-*`、`minio-data-*`、`dev_node_modules`、`mock_node_modules`)。不得改 `depends_on` 健康门控。不得移除 `${VAR:?…}` 守卫。
5. **绝不 `docker compose … down -v`**(会毁 postgres 数据、bundle keys、MinIO 对象,且 MinIO bucket 初始化须手工重做)。收尾用裸 `down`。
6. **`TEST_DATABASE_URL` 绝不指向 `db-debug`/`pgdata-dev`**——`db-debug` 与 `db-dev` 共享 `pgdata-dev` 卷,误指会清空开发数据。集成测试只用 `db-test`(`pgdata-test`,127.0.0.1:5432)。
7. **TDD**:先写/改测试跑红,再实现跑绿;报告里贴 RED/GREEN 的命令与输出摘要。
8. **每任务独立 commit**,前缀 `fix(security):` / `fix(config):` / `feat(observability):` / `chore(deploy):` + 任务号;**CHANGELOG 不由任务写**,控制者波次末统一补。
9. **分支纪律**:commit 前 `git branch --show-current` 必须是本任务指定分支;不 merge、不 push、不碰 wrapper 仓(ROADMAP/spec/plan 记账归控制者)。
- `crearte-server` → `fix/p11-server-hardening`
- `crearte-deploy` → `feat/p11-trusted-proxies`
- `crearte` → `feat/p11-trusted-proxies`
10. **精确使用 spec 给定值**:subnet 与 `TRUSTED_PROXIES` 默认值均为 `172.28.0.0/24`(已核实宿主 `172.17.0.0/16` docker0、`172.18.0.0/16` baota_net 之外空闲);`X-Real-IP` 用 `$remote_addr`;`Referrer-Policy` 用 `strict-origin-when-cross-origin`;`X-Content-Type-Options` 用 `nosniff`;两个 `add_header` 都带 `always`。
11. **数量/数值必须实测核实**:简报或 spec 里引用的计数(几处 `location`、几个 `add_header`、几个限流器)动手前自己 grep 核准,发现不符以实测为准并在报告里披露。本仓上一波(P9-B)控制者连错两次数字(对比度 3.16 应为 2.83、「19 个 th」是行计数应为 24 元素),均由实现者纠正——这条纪律有效,继续保持。
12. **验收命令**(每任务收尾自跑并贴摘要):server 任务 = 本包 `go test` + `go vet ./...` + `gofmt -l`(应无输出);deploy/crearte 任务见各自简报。全量腿由控制者波次末跑。
## 任务分解与并行度
文件面两两不相交才可并行。同一 Go 包内并发编辑会让兄弟任务的 `go test` 看到半成品而假红,故 **T1 与 T2 同属 `internal/api`,必须串行**。
| 任务 | 仓 / 包 | 改动文件 | 波次 |
|---|---|---|---|
| T1 限流表硬上界(缺陷 B) | server / `internal/api` | `ratelimit.go`、`ratelimit_test.go` | **1** |
| T2 信任代理链 + 启动告警(缺陷 A、D-C) | server / `internal/api` | `router.go`、新 `clientip_test.go`、`router_test.go` | **2**(T1 后) |
| T3 LOG 归一 + `parseTrustedProxies` 覆盖(缺陷 C) | server / `internal/config` | `config.go`、`config_test.go` | **1** |
| T4 compose 固定 subnet + `TRUSTED_PROXIES`(D-A) | deploy | `docker-compose.yml`、`.env.example`、`README.md` | **1** |
| T5 nginx 反代头加固(D-E) | crearte | `deploy/nginx.conf.template` | **1** |
| T6 验收(控制者本人,不派发) | 三仓 | — | **3** |
**波次 1 并行**:T1 + T3 + T4 + T5(四个不同仓/包,零文件重叠)。
**波次 2**:T2(等 T1 落地,同包串行)。
**波次 3**:T6 控制者验收 + 终审。
## T6 验收腿(控制者本人)
1. **server**:`gofmt -l`(空)、`go vet ./...`、`go test ./... -count=1`、`go test ./... -race -count=1`、`go build ./...`;集成腿用 `db-test`(`docker compose --profile debug up -d db-test` 后 `TEST_DATABASE_URL=postgres://crearte:<pw>@127.0.0.1:5432/crearte?sslmode=disable go test ./... -count=1`),确认 skip 守卫数为 0。
2. **deploy**:四 profile `docker compose --profile dev|prod|debug|mock config -q` 全过;`config` 输出断言 `api-prod`/`api-dev` 的 `TRUSTED_PROXIES` = `172.28.0.0/24`、`networks.default.ipam.config[0].subnet` 同值、**卷名集合与改动前逐字相同**。
3. **crearte**:五腿全量不回退(vitest **626** / vue-tsc 0 / build OK / 主 e2e **80+1skip** / noauth **4**)——nginx 模板属部署资产,前端测试不应受影响,但必须实跑确认。
4. **端到端(缺陷 A 的回归钉桩,最关键一腿)**:`docker compose --profile dev up -d --build` → `ps` 健康 →
- 启动日志**无** D-C 告警(因为 T4 注入了 `TRUSTED_PROXIES`);
- 用两个不同 `X-Forwarded-For` 各打 `/api/auth/login` 若干次,确认**各自独立计数**(改前是共享桶 → 第二个用户首次即 429;改后两个用户互不干扰);
- 伪造抗性:客户端自带 `X-Forwarded-For: 8.8.8.8` 打一次,确认服务端日志/限流键取的是**网关追加后的真实值**而非 `8.8.8.8`;
- `nginx -t` 校验模板渲染;确认新安全头出现在响应里(含 4xx 路径,验 `always`)。
- 收尾 `docker compose --profile dev down`(**绝不 `-v`**)。
5. **prod profile 冒烟**:`--profile prod up -d --build` → `/healthz` + `/metrics` → `down`。
## 记账(控制者,波次末)
- `crearte-server/docs/CHANGELOG.md` → **0.17.0**;`crearte-deploy/docs/CHANGELOG.md` → **0.7.0**;`crearte/docs/CHANGELOG.md` → **0.24.1**;wrapper `docs/CHANGELOG.md` → **0.3.4**。四仓均双语(同条目英中相邻行、条目间空行、高版本在上)。
- 三内仓各自 `git merge --no-ff <branch>` → push → 删分支(内仓不得在 master 直接 commit)。wrapper 可 master 直提(2026-09-29 已获批)。
- wrapper `docs/ROADMAP.md`:新增 P11 行 + 文档索引表补 spec/plan 两行(AGENTS.md 硬性要求)。
- `memory/2026-10-02.md`:记录本波缺陷链(A 掩盖 B)、spike 方法论、以及「修一个缺陷可能让另一个从不可达变可达」这条教训。
## 打磨批 / 挂账(不在本批)
- CSP(iframe + Service Worker 加载用户作品,需独立设计批)。
- 共享限流存储(Redis 等)——多实例化前置条件,见 spec D-G。
- P9-B 打磨批四条(vue-router 升级复看弱断言、撤评语义进可访问名、手动关最新 toast 的播报重念、spec 口径已修)。
- 备份恢复演练实证(`backup.sh`/`restore-drill.sh` 已交付但 prod 栈演练未跑,属 owner 手动尾巴)。
@@ -0,0 +1,55 @@
# P12 窄屏溢出修复与打磨批 — 实现计划
- spec:`docs/specs/2026-10-02-p12-narrow-viewport-overflow-design.md`(权威,含全部实测数字与决策理由)
- 证据:`crearte/.superpowers/sdd-p12/FINDINGS-survey.md`(16 轮 spike 实测归档)
- 分支名:三仓统一 `fix/p12-narrow-viewport-overflow`
- 基线:crearte `d25c497`(0.24.1)/ crearte-server `9da39b6`(0.17.0)/ crearte-deploy `740958a`(0.7.0)
## 任务切分与波次
**派发结构(依 `sdd-parallel-dispatch` 技能 §1「一仓一写者」)**:T1/T2/T3/T5 全在 crearte **同仓同分支**,共享 git index → 四路并行会争用 `.git/index.lock`,故**合并为单个子代理**(四任务文件互不重叠,单写者无内部冲突)。三仓三路并行:crearte 子代理 + 控制者本人做 T6(server)/T7(deploy)——沿用 P11-T5「小而机械、控制者已元素级核实前提」的控制者自做范式(T6 前提已实测:`applyLogging` 确实回显归一化值、`strings` 已 import;T7 插入点已定位:`validate.yml` 第 19-29 行 step 之后)。
**T4 从「波次 2」提前为 crearte 子代理的第一步(RED→GREEN)**:守卫断言的正是修复后状态,先写守卫 → 跑出 RED(复现 spec §1 的 +380/+325/+76/+21/+3 实测数字)→ 再实现 T1/T2/T3 直到 GREEN。好处:① 消除 throwaway 验证脚本(不必像勘查期那样写完再删);② **RED 输出本身就是「守卫有牙」的证明**,无需事后再临时 revert 一处修复来自证;③ 实现者拿到真实反馈而非推断。T5 打磨三项不涉溢出,排在 GREEN 之后。
### crearte 子代理(单写者,TDD 顺序)
| 任务 | 仓 | 文件 | spec | 要点 |
|---|---|---|---|---|
| T1 页头用户名截断 | crearte | `app/components/AppHeader.vue`、`app/components/AppHeader.test.ts` | §3.1 / D-A / D-G | summary 改 flex 三件 + `max-w-[6rem]` + `:title`;`▾` 独立 span 带 `aria-hidden`;**保留 `ref="summaryRef"`**(Esc 回焦依赖) |
| T2 五表格滚动包裹 | crearte | `app/views/AdminUsersView.vue`、`AdminView.vue`、`AdminAuditView.vue` | §3.2 / D-B / D-C / D-D | 包裹层 `relative overflow-x-auto pr-1 pb-1`;**三处 `v-else` 上移到包裹层**;表格内部(`scope`/sr-only/`data-testid`)一字不动 |
| T3 目录排序 + 账号昵称 | crearte | `app/components/ResultMeta.vue`、`app/views/AccountView.vue` | §3.3–3.4 / D-E / D-F | ResultMeta `:22` `shrink-0`→`min-w-0`(同排另两个 `shrink-0` **不动**);AccountView `:190` 加 `min-w-0 wrap-anywhere` |
| T5 打磨三项 | crearte | `app/router/index.test.ts`、`app/components/GameReactions.vue` + 其 test、`app/components/ToastHost.vue` + 其 test | §3.5(a)(b)(c) | P9B-1 直测 `focusMain()`;P9B-2 动态 `aria-label`(仅 `rated && i===score` 分支改写);P9B-4 `announcedId` 单调比较 + 新单测 |
| T6 配置错误消息钉桩 | crearte-server | `src/internal/config/config_test.go` | §3.5(d) | `LOG_LEVEL=" BOGUS "` 断言 err 含 `bogus` 且不含原样 ` BOGUS `;先核 `strings` import 与 `applyLogging` 现有文案 |
| T7 CI 空默认守卫 | crearte-deploy | `.github/workflows/validate.yml` | §3.5(e) | 新 step 三态断言(空默认 / 不得为 CIDR / 覆盖仍生效);**本地实跑 run 块含反向验证** |
### 控制者本人(与 crearte 子代理并行,各自独占一仓)
| 任务 | 仓 | 文件 | spec | 要点 |
|---|---|---|---|---|
| T6 配置错误消息钉桩 | crearte-server | `src/internal/config/config_test.go` | §3.5(d) | 前提已核实:`config.go` `applyLogging` 错误文案为 `fmt.Errorf("config: LOG_LEVEL: invalid value %q", cfg.LogLevel)`,`cfg.LogLevel` 已是归一化后的值 → **回显成立,纯补测试**;`strings` 已在 test import 中 |
| T7 CI 空默认守卫 | crearte-deploy | `.github/workflows/validate.yml` | §3.5(e) | 插入点已定位:`compose` job 内第 19 行 step 之后、第 30 行 step 之前;**本地实跑 run 块三态**(当前通过 / 注入 CIDR 应失败 / 覆盖 `10.0.0.0/8` 应通过) |
(T4 两层机器守卫已并入 crearte 子代理的第一步,见上。)
## 验收口径(合并前必跑,全量)
- **crearte**:`npm run test`(基线 626 + 新增)、`npm run typecheck`、`npm run build`、`npm run build:runtime`、`npm run e2e`(基线 80+1skip + 新增 responsive)、`npm run e2e:noauth`(基线 4)。脚本名以 `package.json` 为准,**禁用 `--if-present`**(P11 假绿事件)。
- **crearte-server**:容器化 Go 1.24(`GOPROXY=https://goproxy.cn,direct`、`GOSUMDB=sum.golang.google.cn`、命名卷 `crearte_gomod`/`crearte_gocache`):`gofmt -l .` 空 / `go vet ./...` 0 / `go build ./...` 0 / `go test ./... -count=1` 11 包 ok。
- **crearte-deploy**:四 profile `config -q`;卷名集合与基线逐字相同;T7 守卫脚本本地三态实跑。
- 退出码:管道后一律 `set -o pipefail` 或不管道(P11 三度踩坑)。
## 纪律(延续 P9-B / P11 教训)
1. 派发简报引用的**每个数量**由控制者预先元素级 grep 核准(P9-B「19 个 th」教训)。
2. 简报里对既有代码/环境行为的**事实陈述**同样要核实(P11 四次被抓)。
3. 任务级审查 → 控制者裁定 → 波次末全分支终审(最强模型),终审须独立复跑验收腿并对账。
4. 审查依据是 **spec §3 全量**,不是任务书(P6 D-D 教训:任务书漏列项审查扯不出)。
5. 内仓禁 master 直提;合并用 `--no-ff`;push 不用管道且推后 `ls-remote` 非空对账。
6. **本批不做的**:D4(nav 逐字换行,park 待设计决策)、汉堡菜单(已证伪)、CSP/HSTS/TLS(独立批)、后端校验规则(60 字是既有合法契约)。
## 挂账处置(随本批记账)
- ROADMAP / 账本中「移动端页头 / 汉堡菜单」→ 标注**已证伪删除**(nav 内容宽 74–158px,320–1280px 零溢出)。
- D4(nav 链接逐字换行)→ 标注 **park**:外观级、无溢出无功能损失,修法在 320px 与 D1a 争空间,需设计决策(更短名上限 / 汉堡菜单 / nav 缩写)。
- P9B-3(spec「19 个 th」口径)→ 已在 P9-B 波次内修完,不属本批。
- P11 其余次要 notes(T1 按需回收、T2 注释语言、T2 共享片段唯一性、T3 `" warn "` 未断言 LogFormat、T4 README 窗口口径、T5 过程性陈述)→ **纯留档,无可动手改动**,不入本批。
+95
View File
@@ -0,0 +1,95 @@
# P13 实现计划:暗色模式
- **spec**:`docs/specs/2026-10-03-p13-dark-mode-design.md`(权威依据,冲突时以 spec 为准)
- **证据归档**:`crearte/.superpowers/sdd-p13/FINDINGS-survey.md`(7 轮 spike 实测)
- **分支**:`feat/p13-dark-mode`(已建,从 crearte `e59a171` 起)
- **范围**:仅 `crearte`。**`crearte-server` 与 `crearte-deploy` 零改动**(spec §4 边界)。
- **版本**:crearte 0.26.0 / wrapper 0.3.6(记账由控制者做,实现者**禁动** ROADMAP、CHANGELOG、`docs/`)。
---
## 任务切分与波次
全部任务都在 `crearte` 单一工作树与 git index 上 → 按 `sdd-parallel-dispatch` §1「一仓一写者」,**交给一个子代理串行完成**,不拆并行(拆了就是四路争用同一个 `.git/index.lock`,P12 已验证这条纪律)。
**TDD 顺序:守卫先行**。T1 写守卫并**看它红**(RED 输出必须复现 spec 引用的实测数字),再 T2 让它绿。P12 的经验:这个顺序使 RED 输出本身成为「守卫有牙」的证明,免去事后 revert 自证那一轮。
| 序 | 任务 | 文件 | 完成判据 |
|---|---|---|---|
| **T1** | 守卫重构为双主题(**RED 先行**) | `app/lib/contrast.test.ts` | 按块解析(亮/暗各自 Map);两块各断言 12 令牌齐备;双主题各断言 spec §1.3/§7 的 12 条真实配对(阈值:正文 4.5 / wordmark 大字号 3.0 / 焦点环非文本 3.0);`scrim` 分离**按主题钉实际分离侧**(D-F):亮色钉填充侧 `paper vs scrim ≥3`(17.60)、暗色钉边框侧 `ink vs scrim ≥3`(17.60)——**不得写成 `max(两侧) ≥3`**,两侧互补使其理论下界 = √16.50 = 4.0621 > 3 → **数学恒真、永不可能失败**(审查 finding #1,控制者暂力重算证实;实证:亮 scrim 改纯白得填充侧 1.1165 但 max()=18.42 → 仍绿)。也不得只钉 `ink vs scrim` 于两主题(亮色仅 1.07 → 误红);含「旧单 Map 解析器对含暗色块的 CSS 会得出错误结果」的反面钉桩(防后人简化回去)。**此时暗色块尚不存在 → 必须 RED**,红输出须显示「暗色 12 令牌缺失」 |
| **T2** | 令牌层落地 | `app/styles/main.css` | `@theme` 新增 `--color-skeleton: #efe9da` / `--color-scrim: #0d0b08`;五个 `--shadow-hard*` 内联 hex → `var(--color-ink)` / `var(--color-accent)`(D-D);追加 `html[data-theme="dark"] { … }` 全量 12 令牌 + `color-scheme: dark`(D-B/D-C,**必须** `html[data-theme=dark]` 特异性 (0,1,1),禁裸属性选择器、禁 `!important`、禁 `@apply` 绕过);`::selection`/`strong`/`.wordmark-label` **零改动**(D-A 核心收益)。**T1 由红转绿** |
| **T3** | 三处 usage 迁移 + 硬编码色守卫 | `ResultMeta.vue:52`、`StatePanel.vue` ×7、`FilterDrawer.vue:32`、新守卫文件 | ① `bg-accent text-ink` → `bg-accent-ink text-paper`(唯一把 accent 当背景的地方,迁移后 accent 纯非文本);② `bg-[#EFE9DA]` ×7 → `bg-skeleton`;③ `backdrop:bg-ink/60` → `backdrop:bg-scrim/80`(D-E)。新增源码级守卫扫描 `app/**/*.vue` **禁止** `bg-[#`/`text-[#`/`border-[#` 硬编码 hex,**RED 先行**(迁移前应报 7 处违规),且**必须沿用 P12 `tableOverflow.test.ts` 的屏蔽范式**(`<script>`/`<style>`/HTML 注释/`<textarea>`/`<title>` → 等长空白),否则注释里的示例会误报(P12 FE-1 假绿同源教训) |
| **T4** | 主题机制 | `app/composables/useTheme.ts`(新)、`index.html`、`app/App.vue`、`app/components/AppHeader.vue`、`useTheme.test.ts`(新)、`AppHeader.test.ts`(扩展) | 按 spec §3.5–§3.8 逐字落地。**关键约束**:开关**必须** `h-8 w-8`(32px;spike 3 实测 28px 在最坏格 margin 仅 5px);容器行 `gap-6`→`gap-2 sm:gap-6`、nav `gap-4`→`gap-2 sm:gap-4`(候选 B,最坏格 margin=16);**不得**改 `<summary>` 的 `max-w-[6rem] min-w-0` 与两 span 结构(P12 F3 钉桩);**不得**改 logo 文字/字号;`index.html` 内联 pre-paint 脚本的判定优先级须与 `useTheme.ts` **逐字一致**(stored → `prefers-color-scheme` → light);`useTheme` 若做模块级单例**必须**提供 `__resetTheme()`(P9-B D-I 跨测试污染教训) |
| **T5** | e2e | `e2e/dark.spec.ts`(新) | spec §5-T5 的 7 条最低覆盖:默认跟随系统(**且在 Vue 挂载前就已设置** = 验 D-K 无 FOUC)、开关切换(`data-theme` + `theme-color` meta + localStorage 三者同步)、reload 持久化、**计算值实证**(body `rgb(23,20,15)`/`rgb(247,242,231)`,且某 `.shadow-hard` 的 `box-shadow` 含 `rgb(247,242,231)` = spike 6 传导在真产物上复验)、遮罩不泛白、开关 `toHaveAccessibleName` 含当前态、暗色下 @320/@375 登录态 ascii60 零横向溢出 |
### 全量回归(实现者交付前必跑,`set -o pipefail`,**禁 `--if-present`**)
| 腿 | 命令 | 基线(P12 合并态,**不得回退**) |
|---|---|---|
| vitest | `npm run test` | **635 passed / 74 files** |
| typecheck | `npm run typecheck` | exit 0 |
| build | `npm run build` | exit 0(含 `vue-tsc --noEmit`) |
| build:runtime | `npm run build:runtime` | exit 0 |
| 主 e2e | `npm run e2e` | **92 passed + 1 skipped**(含 P12 的 12 条 responsive 腿) |
| noauth e2e | `npm run e2e:noauth` | **4 passed** |
新增测试后数字应**上升**;任何既有腿下降必须解释。**P12 的 12 条 responsive 腿与 `AppHeader.test.ts` 的 F3 形态钉桩属不得回退项**——若 gap 变更导致某腿数值变化,**不得改断言迁就**,先查明是否真溢出。
### 构建产物核验(T2 后必做,grep dist CSS)
- `var(--color-*)` 引用数 **≥ 64**(不得下降)
- 内联 `#141414` 从 **11 降到 ≤ 3**,且逐处说明剩余来源
- ⚠️ **测试/注释里禁写已迁移掉的 Tailwind 类名字面量**(实现者 T2 期发现,spec 与 FINDINGS 均未预料)。Tailwind v4 的自动内容探测**扫描全部源文件,含 `.test.ts`**:`FilterDrawer.test.ts` 里一句 `not.toContain('backdrop:bg-ink/60')` 断言(及其注释)让 Tailwind 把这个类名当候选、**重新生成 utility 及 `#14141499` fallback 烧回产物**,使该计数停在 **3** 而非 2。修法:用**字符串拼接**构造字面量(`'backdrop:bg-i' + 'nk/60'`)。与 P12 屏蔽范式同源,方向相反——那里防「守卫扫到注释里的 table」,这里栽在「Tailwind 扫到测试里的死 utility」。任何「某 utility 不得再出现」的回退钉桩都必须拼接。
- 暗色块出现在产物中且未被 Tailwind 层吞掉
- ⚠️ **grep 模式必须容忍 minifier 去引号**(实现者发现、控制者独立复现 2026-10-03):本 plan 与 spec 原先写 `grep -c 'html\[data-theme="dark"\]'`(带双引号),但 lightningcss **会去掉属性选择器里非必需的引号**,产物实际是 `html[data-theme=dark]` → 带引号的字面模式**返回 0,会被误判为「暗色块被吞掉」**。正确:`grep -c 'html\[data-theme="\?dark"\?\]' "$CSS"` 或去引号版,须 ≥ 1。**源码里仍须写带引号的 `html[data-theme="dark"]`**(CSS 源码选择器)——错的只是产物核验的 grep。
- `.shadow-hard` 的 `--tw-shadow` 含 `var(--color-ink)`(spike 6 传导前提)
---
## 验收口径
实现者报告须含:六腿实跑输出摘录(数字,不是「应该没问题」)、构建产物四项 grep 结果、T1/T3 的 **RED 输出原文**(须复现 spec 引用的实测数字,如暗色 `ink on highlight` = 1.46)、**mutation 自查输出**(见下)、throwaway 清理证明(`porcelain=0`)。
### mutation 自查(实现者必做并附输出)
1. **亮色守卫仍被守**(D-H 的核心风险 = 重构把亮色守卫静默弄丢):把亮色 `--color-success` 临时改成低对比值 → **亮色腿必须红**;改回 → 绿。
2. **暗色守卫有牙**:把暗色 `--color-highlight` 临时改成 `#f5c518`(亮黄)→ `ink on highlight` 暗色 = **1.46 红**。
3. **硬编码色守卫有牙**:临时在某 `.vue` 加 `bg-[#123456]` → 守卫红;删除 → 绿。
4. **P12 成果未回退**:`npx vitest run app/lib/tableOverflow.test.ts app/lib/tableScope.test.ts` 仍绿;`responsive.spec.ts` 12 腿仍绿。
每次 mutation 后 `git checkout --` 恢复并核对 `git diff` 为空。
---
## 纪律(延续 P9-B / P11 / P12 教训,spec §8 全文适用)
1. **不推断 CSS/Vue/构建行为,只实测。** 本批已有两次栽在推断上:spike 5 用运行时注入验证**构建期烘焙**(失败:令牌已变而 `box-shadow` 仍 `rgb(20,20,20)`)、spike 2 第一版用 `APPLY.toString()` + `new Function` 序列化注入(`ReferenceError: APPLY is not defined`)。
2. **守卫先写、先看它红**;红输出须复现 spec 数字,否则守卫可能无牙。
3. **守卫自身要受同等审视**(P12 FE-1:注释掉包裹层守卫仍绿 = 假信心)。
4. **P12/P9-B 成果不得回退**:`max-w-[6rem]`、五表格包裹层 `relative overflow-x-auto pr-1 pb-1`、`ResultMeta.vue:22` 的 `relative min-w-0`、`AccountView` 的 `min-w-0 wrap-anywhere`、skip-link 首子位置、`useToast` 的 `announcedId` 单调语义、`scope="col"` 计数 13/6/5。
5. **throwaway 跑完即删**;注意 `npm run build` 含 `vue-tsc --noEmit` 会检查 `e2e/*.spec.ts` 类型(spike 6 曾因此 `BUILD_EXIT=2`)——spike 期间可用 `npm run build:e2e` 绕开,但**交付前 `npm run build` 必须真过**。
6. **禁动**:`docs/`(ROADMAP、CHANGELOG、spec、plan)、`.superpowers/`(只写自己的报告)、`crearte-server/`、`crearte-deploy/`、`playwright.config.ts`、`.gitignore`。
7. **发现 spec 有误就纠正 spec 并在报告里说明**,不要迁就实现(P12 实现者纠正了 768px 归因,控制者用 spike 17 独立定案其成立并 re-pin 五处下游)。
8. commit 信息含任务号与决策号(`P13-T<n>` / `D-<X>`);**禁 `git add -A`**(`.superpowers/`、`src/test-results/` 不得入库)。
---
## 挂账处置(随本批记账,由控制者执行)
- **清偿**:ROADMAP P9-B 行尾「C 暗色模式」→ 标注已由 P13 清偿。
- **新增挂账**:
- 第三态「恢复跟随系统」(spec §4:header 无像素容纳带标签控件;三态循环在 32px 无标签按钮上不可发现)
- CSP 设计批(内联 pre-paint 脚本届时需 `nonce` 或外置 —— spec §3.6 已留注释提示)
- OG 社交卡片、播放计数、TLS+HSTS、D4 nav 逐字换行设计决策、备份恢复演练实证(owner 手动尾巴)
- 上传面一处低危 fallthrough(`uploads.go:30-36`:`ParseMultipartForm` 返回非 `MaxBytesError` 时不 return,落到 switch 给出误导性的 400「kind must be bundle or cover」而非真实原因)—— 本轮证据扫描发现,属 server 侧打磨候选
- `--color-info` 为**死令牌**(`bg-info` / `text-info` 实测均 0 实例,仅 1 处 token 定义)—— 清理或启用,属代码优雅候选
- **bundle-key 端点的公开信任边界无决策记录 + 一条误导性死管道**(server 侧,零行为变更的微批候选)。P13 勘查期的只读审计发现,**非缺陷,是文档缺口**:
- 事实:`router.go:104` 的 `engine.GET(RouteBundleKey, limiter.Middleware(), deps.BundleKey.Get)` 是路由表里**唯一没有 `RequireUser` 的数据端点**。
- **定性为有意设计的证据(共 7 处钉桩,非顺带覆盖)**:server 侧 **6 处**显式无鉴权断言 —— `router_test.go:62`(用**完全不带 `Authorization` 头**的 `httptest.NewRequest` 断言 **200** + `Cache-Control: no-store`)、`router_test.go:85`(无头 → 第 61 次 **429** + `Retry-After`,即限流是边界而非鉴权)、`admin_test.go:121`(`doRequest(..., "", "")` token 为空 → 200)、`integration_test.go:128`(同 → 200)、`integration_test.go:153`(同 → **410**)、`admin_works_test.go:120`(同 → 200 后吊销变 410);前端侧 `e2e/full-loop.spec.ts:112-118` 从**全新无 token 浏览器上下文** `request.get` 断言 **410**。若该路由挂了 `RequireUser`,这些请求会先返 **401**、永远到不了吊销/限流分支 —— 故「无鉴权可取钥」是被**多点钉死**的既有行为。
- 支撑链:MinIO 桶 `mc anonymous set download`(deploy README:26/41,密文公开可下载)→ `content.go:202` 经 `PublicURL(ObjectKey)` 把 bundle URL 交给**公开的** game detail 端点 → `noauth.spec.ts`(4 条)证明站点支持全匿名模式。即密文与 CEK 双双公开,**已发布作品对任何人可下载**——这与产品定位(公开托管社区)一致。
- 真实信任边界 = **60/min 限流 + 管理员吊销(410)**,不是鉴权。加密在此的作用是**可吊销的交付机制**(`bundle.UnwrapCEK` + `row.RevokedAt`),不是访问控制。
- **误导性死管道**:`runtime/sw/keyfetch.ts:6` 有 `token?: string`,`runtime/sw/index.ts:82` 一路透传,`keyfetch.test.ts:28-33` 断言 Authorization 头透传,`cors.go:31` 的 ACAH 也允许 `Authorization`——但服务端**从不要求、从不校验**它。
- 风险:将来有人「修好」缺失的 `RequireUser` → **匿名访客将无法游玩任何作品**(站点必须支持登出态游玩)。上述 7 处钉桩会以 401≠200/410/429 立即抓住,**故静默破坏风险实际很低**。真正的缺口只剩一个:`router.go:102-105` 处**没有任何注释说明这个端点为何故意不挂 `RequireUser`**,而它是路由表里唯一没有鉴权的数据端点——视觉上极像遗漏。
- 处置(P11「大声留痕」哲学的同款应用,零行为变更):**仅需① 在 `router.go` 该处加决策记录注释**,写明「有意公开:匿名访客必须能取钥游玩;信任边界是 60/min 限流 + 管理员吊销(410),不是鉴权;密文本身亦经 MinIO 匿名 download 公开,加密在此是可吊销的交付机制而非访问控制;加 `RequireUser` 会破坏登出态游玩并被 `router_test.go:62`/`integration_test.go:153`/`full-loop.spec.ts` 抓住」。
- ~~② 补反面钉桩测试~~ —— **撤销此建议**:`router_test.go:62` 已经就是那条测试(不带 Authorization 头断言 200)。我先前写「e2e 顺带覆盖」是**未核实就下的判断**,实际有 6 处 server 侧显式钉桩。教训:登记挂账前必须先 grep 既有测试,否则会开出「补一条已存在的测试」这种伪工作。
- ③ `keyfetch.ts:6` 的 `token?: string` 管道待查清后再定(属 crearte,P13 实现者正独占该仓,本批不动)。
@@ -0,0 +1,92 @@
# P14 实现计划:拆分 `ContentService` god object
- **spec(权威)**:`docs/specs/2026-10-03-p14-content-service-split-design.md`
- **取证账本**:`crearte-server/.superpowers/sdd-p14/survey.md`(全部数字实测,含 Route C spike 输出与三次归属分析的自我纠错)
- **仓**:`crearte-server`(**仅此一个**)· **base** `dbf7fe5`(0.17.2)· 分支 `chore/p14-content-service-split`
> ⚠️ 分支前缀用 `chore/`,**不是 `refactor/`**:AGENTS.md 只允许 `{feat|fix|docs|chore}/` 四种前缀(P12 用 `fix/`、P13 用 `feat/`)。纯结构重构归 `chore/`。
- **性质**:纯结构重构,**零行为变更**
- **目标版本**:crearte-server **0.18.0** · wrapper **0.3.7**
- **派发**:**一个**实现者子代理做完 T1→T3(所有任务同动单一工作树与 git index,`sdd-parallel-dispatch` §1 一仓一写者)
> spec 与本计划冲突时 **spec 赢**。spec 全文(§0–§8)必读,尤其 §2 决策表 D-A…D-K、§3 实现要求(含逐字代码骨架与机械迁移规则)、§5 测试计划、§8 实现纪律(10 条累计教训)。
---
## 任务表
| 任务 | 交付物 | 要求 | 完成判据 |
|---|---|---|---|
| **T1** | `internal/service/architecture_test.go`(新增) | **RED 先行**(spec D-J / §5-T1)。三类 reflect 断言:① `ContentService` 恰 5 字段、全 `Anonymous`、全 `Ptr`、类型名集合恰为五个子 service ② 五个子 service 各自的**导出方法名集合逐字等于**其职责线预期集合(spec §5-T1.2 列全)③ `ContentService` 与五个子 service **都不得有名为 `now` 的字段** | T1 单独提交时 `go test ./internal/service/` 以**编译错误**失败(`undefined: CatalogService` 等),输出**逐字存证**到 `crearte-server/.superpowers/sdd-p14/impl-evidence/t1-red.txt`。Go 的 RED 不给断言级消息,**编译错误本身即 RED 证据,但必须存档** |
| **T2** | `catalog.go` / `reaction.go` / `upload.go` / `submission.go` / `moderation.go`(新增)+ `content.go`(改为组合根 + 共享声明) | 按 spec §3.1–§3.2 机械迁移。**方法体逐字不动**(规则 5);私有 helper 随唯一使用线迁移(规则 2);每个文件 import **只列实际用到的包**(规则 4);删除 `ContentService.now`(D-H),并**自行 grep 确认** `time` import 是否变悬空(spec §3.1 警告,不许凭 spec 推断) | T1 由红转绿;`go test ./...` **11 包全绿**;**既有 `*_test.go` 修改文件数 = 0**(`git diff --stat` 只应出现 T1 新增的那个);`content.go` < 120 行;六文件最大 < 300 行 |
| **T3** | 五个 handler 的窄接口(D-E)+ `cmd/catalog.go` 依赖面收窄(D-I) | 按 spec §3.3–§3.4。接口**定义在消费方**(handler 自己的文件内,小写包内私有),方法签名**从 service 侧逐字复制**(spec §3.3 警告:`ListPublished`/`GetPublishedDetail` 含未命名的 ETag `string` 返回值,须 `grep -n 'func (s \*ContentService) ListPublished' -A2` 取原文,**不要凭 spec 省略号推断**);`admin.go` 的 `bundle *service.BundleService` 与 `uploads.go` 的 `maxBundle, maxCover int64` 参数**保持不变** | `grep -rn 'service.ContentService' internal/handler/` → **零命中**;`cmd/catalog.go` 内 `nil` 占位与 `NewPostgresUserStore(pool)` 消失(依赖槽 4→2);**`cmd/serve.go` 零改动**(spike H2 的可核推论——若被迫改动,说明窄接口方法集抄漏了,按编译错误补全而非放宽接口);既有 handler/api 测试**断言零修改** |
---
## 验收(控制者独立复跑,不采信自报)
### 四门(dockerized Go,AGENTS.md 红线)
```bash
docker run --rm -v "$PWD/src:/src" -w /src \
-v crearte_gomod:/go/pkg/mod -e GOCACHE=/gocache -v crearte_gocache:/gocache \
-e GOPROXY=https://goproxy.cn,direct -e GOSUMDB=sum.golang.google.cn \
golang:1.24-alpine sh -c 'gofmt -l . && go vet ./... && go build ./... && go test ./...'
```
基线(已实测):`gofmt -l .` 空 · `go vet` 净 · `go build` exit 0 · `go test ./...` **11 包 ok**(api 最慢 ~11.5s 含真库集成)。**host Go 1.18 不可用**;缺 `GOPROXY=https://goproxy.cn,direct` 会让下载挂死并杀死代理。冷构建 1–3 min → background + 耐心 poll。
### 结构指标(spec §5 表,交付时须报实测数字)
| 指标 | 基线 | 目标 |
|---|---|---|
| `content.go` 行数 | 856 | **< 120** |
| 六文件最大行数 | 856 | **< 300** |
| `service/` 内 >400 行生产文件 | 1 | **0** |
| handler 里 `service.ContentService` 引用 | 10 处 | **0** |
| `cmd/catalog.go` 的 `nil` 占位 | 1 | **0** |
| `ContentService` 自身声明的方法数 | 25 | **0** |
| 既有 `*_test.go` 修改文件数 | — | **0** |
### mutation 抽查(spec §5-T1 的 a/b/c,控制者独立复现,不复用实现者结果)
- (a) 把 `CoverURL` 从 `catalog.go` 移回 `content.go` 并改接收者为 `*ContentService` → 架构守卫必须红
- (b) 给 `ContentService` 加一个具体字段(如 `store repository.ContentStore`)→ 必须红
- (c) 给任一子 service 加回 `now func() time.Time` → 必须红
每次 mutation 后 `git checkout -- <file>` 恢复并核 `git diff` 空。**修守卫必须两个方向都验**(对合法值不误红 + mutation 下不误绿)——P13 控制者正是只验了前者,交付了一个数学恒真的断言。
### 行为不变的额外证据
- `git diff dbf7fe5..HEAD --stat`:既有 `*_test.go` **零文件**出现在 diff 里(除 T1 新增)
- 逐函数比对:方法体应只有接收者类型变化,**无逻辑改动**(审查者用 `git diff` 核,不信报告表格)
- 错误值文本、slog 字段、仓储调用顺序、事务边界**逐字不变**(spec §3.2 规则 5 / D-G)
---
## 边界(**不做**,spec §4)
不改任何行为 · 不重构 `Approve` 内部(170 行 7 阶段,登记挂账留下一批)· 不动 `AccountService`/`AuthService`/`BundleService`/`CleanupService` · 不动 `memory_content.go`(814 行但实测是测试替身)· 不做 handler 错误映射去重(候选 C)· 不引入新依赖 · 不改任何测试断言语义。
**若实现者发现必须改某个既有测试才能编译 → 停下来报告,不要改。** 那本身说明拆分做错了(D-A 的设计目标就是构造点零改动)。
---
## 记账(控制者做,实现者禁动 `docs/`)
- `crearte-server/docs/CHANGELOG.md` 新增 `## [0.18.0] - 2026-10-03`(插 `## [0.17.2]` 前)
- wrapper `docs/CHANGELOG.md` 新增 `## [0.3.7] - 2026-10-03`(插 `## [0.3.6]` 前,小节 `### Done / 完成`)
- `docs/ROADMAP.md`:新增 P14 行(P13 行后)+ 文档索引 P14 行(P13 索引行后);**哈希引用 merge commit**
- **不 bump 任何 version 文件**(Go 服务无 package.json 类版本文件)
- 挂账新增:`Approve` 170 行 7 阶段内部重构、`memory_content.go` 测试替身规模、handler 错误映射去重(候选 C)、`AccountService`/`CleanupService` 的 `now` 字段使用情况复核(本批只确证 `ContentService.now` 死)
- 格式:同条目英文行紧跟中文行**无空行**、不同条目**空一行**、双语小节标题
- **纪律**:显式 `git add <file>`,**禁 `git add -A`**;wrapper 的 `IDENTITY.md`/`SOUL.md`/`USER.md` 三个未跟踪文件**非本批产物,绝不 stage**
- 合并:inner repo 提交前 `git branch --show-current` 确认不在 master;`--no-ff` 建合并提交;推送**禁管道**;对账用 `git ls-remote` 且**必须校验变量非空**(空变量会让 `[ "$L" = "$R" ]` 假 MATCH)
---
## 审查阶梯(P11–P13 惯例,不可省)
1. **任务级审查**(只读):spec 全文为权威,独立核 §3 逐条落地 + 结构指标 + 自设 mutation(**不照抄**上面的 a/b/c)+ 红线(`docs/`、`go.mod`、其它仓、既有测试断言)
2. **全分支终审**(只读):spec 全文自洽性 + 跨任务缝隙(T2 子 service 与 T3 窄接口的方法集是否逐字对应)+ 审查控制者的裁定工作 + 独立复现 mutation
3. 每道门的 notes **逐条裁定后方可合并**(`sdd-pre-merge-review` §3:未裁定的 note 阻塞合并;「PASS with notes」不等于门过了)
4. **P11 先例:全分支终审裁定过「需修复后合并」**(N1 一条 `slog.Warn` hint 仍在推荐刚被判不安全的配置、N2 spec 五处未随 re-pin 更新)——这道门不是橡皮章
@@ -0,0 +1,123 @@
# P15-A 实现计划:拆分 `Approve` 169 行七阶段
- **spec(权威)**:`docs/specs/2026-10-03-p15a-approve-phases-design.md`
- **取证账本**:`crearte-server/.superpowers/sdd-p15a/survey.md`(控制者派发前落盘;P15 前期取证在 `crearte/.superpowers/sdd-p15/survey.md` §8.1)
- **仓**:`crearte-server`(**仅此一个**)· **base** `fe810dd`(0.18.0,master)· 分支 `chore/p15a-approve-phases`
> ⚠️ 分支前缀用 `chore/`:AGENTS.md 只允许 `{feat|fix|docs|chore}/` 四种(**`refactor/` 不允许**,P14 控制者曾写错)。纯结构重构归 `chore/`。
> ⚠️ inner repo 红线:**不得在 `master` 上提交**。`git commit` 前先 `git branch --show-current` 确认。
- **性质**:纯结构重构,**零行为变更**(spec D-G)
- **目标版本**:crearte-server **0.19.0** · wrapper **0.3.8**
- **派发**:**一个**实现者子代理做完 T1→T3(所有任务同动单一工作树与 git index,`sdd-parallel-dispatch` §1 一仓一写者)
- **并行**:P15-B 在 `crearte` 仓同时进行 → **不得触碰 `crearte/`、`crearte-deploy/`、wrapper 的任何文件**
> spec 与本计划冲突时 **spec 赢**。spec 全文(§0–§8)必读,尤其 **§1.2(④⑥不同形,禁止合并)**、§1.3(helper 形态实测)、§2 决策表 D-A…D-I、§3.2(逐阶段要求,含 6 处"逐字保留")、§5-T1(mutation a–h 及 (b) 的已知盲区警告)、§7 风险表、§8 纪律 10 条。
---
## 任务表
| 任务 | 交付物 | 要求 | 完成判据 |
|---|---|---|---|
| **T1** | `internal/service/function_length_test.go`(**新增文件**,不改 `architecture_test.go`) | **RED 先行**(spec §5-T1)。三条断言:① `TestNoOversizedFunction`:`service/` 内非测试 `.go` 无任何函数 >100 行(阈值依据见 spec §0 普查:当前只有 `Approve` 169 超过,次大 83)② `TestApproveIsSmall`:`Approve` <60 行 ③ `TestApproveHelpersExist`:五个 helper 名存在——**必须用源码文本匹配,不能用 reflect**(spec §1.3:未导出标识符 reflect 不可见)。断言内须含"至少扫到 N 个 `.go` 文件"的前提检查(spec §5 mutation (e) 的教训) | RED 输出**逐字存证** `.superpowers/sdd-p15a/impl-evidence/t1-red.txt`,且**红的原因是断言失败而非编译错误**(与 P14 T1 的编译红形态**不同**:本批引用的都是既有类型)。三条各自的红须可分辨 |
| **T2** | `internal/service/moderation.go`(改) | 按 spec §3.1 骨架 + §3.2 逐阶段要求执行拆分:`loadApprovePlan`(①②③) / `precheckApprove`(④) / `copyApprovalObjects`(⑤,**收 `*approvePlan`**) / `applyApprovalInTx`(⑥,**包级函数**收 `tx repository.ContentRepository`) / `cleanupApprovalUploads`(⑦) + 包级私有 struct `approvePlan`(spec D-B 七字段)。**六处"逐字保留"**见 spec §3.2 的 ⚠️ 标注 | `go test ./... -count=1` **11 包全绿**;**既有 `*_test.go` 修改数 = 0**;`architecture_test.go` **修改行数 = 0**;P14 七断言 `-v` 全 PASS |
| **T3** | 字节级保真证明(加做项,P14 同款) | 写脚本对 base `fe810dd` 的 `Approve` 函数体与新树的 `Approve`+5 helper 做**语句级比对**(归一化缩进与接收者前缀),产出 `verbatim` / `changed`(逐条给理由)/ `missing`(**必须 0**) 三张清单。**脚本的三个已知坑见 spec §5-T3 的 ⚠️**(字符串字面量里的 `//` 被当注释、单行 `var` 须提前终止、意外值先怀疑自己的模式) | 三份清单存证 `.superpowers/sdd-p15a/impl-evidence/t3-verbatim.txt`;`missing = 0`;`changed` 每条有理由且都落在 spec §3.2 允许的范围内 |
---
## 验收(控制者独立复跑,不采信自报)
### 四门(dockerized Go,AGENTS.md 硬红线)
```bash
cd crearte-server && docker run --rm -v "$PWD/src:/src" -w /src \
-v crearte_gomod:/go/pkg/mod -e GOCACHE=/gocache -v crearte_gocache:/gocache \
-e GOPROXY=https://goproxy.cn,direct -e GOSUMDB=sum.golang.google.cn \
golang:1.24-alpine sh -c 'gofmt -l . ; test -z "$(gofmt -l .)" ; echo gofmt_gate=$? ; go vet ./... ; go build ./... ; go test ./... -count=1'
```
基线(spec §0,已实测):**`test -z "$(gofmt -l .)"` exit 0**(⚠️ 审查者 F3:`gofmt -l` 的语义是「列出需要格式化的文件」,**列出时仍 exit 0**,只在解析失败时 exit 2 → `gofmt_exit=0` **不是证据**;须同时存证 `gofmt -l .` 的**原始输出为空**。控制者与实现者共用旧配方,故同一盲点被复算两次而未被发现)
⚠️ **host Go 1.18 不可用**;**必须带 `GOPROXY=https://goproxy.cn,direct`**(`proxy.golang.org` 本机不可达,默认下载**挂死并杀代理**);冷构建 1–3 min → `background: true` + 耐心 poll,**不要 sleep 循环、不要因没立刻返回就断定失败**。
⚠️ **`-count=1` 强制实跑**(禁缓存)。
⚠️ **落盘证据用 `setsid nohup` 脱离 + 宿主侧重定向**(容器只挂 `/src` 写不了 `.superpowers/`;exec session 一断容器就被杀)。
⚠️ **`grep -c` 输出 0 时 exit 1**,放 `&&` 链里会静默中断后续步骤(含 `git commit`)。
⚠️ **`pkill -f <pattern>` 会匹配到自己的命令行**(P14 控制者把自己 SIGKILL)→ 用 `[p]attern` 括号技巧。
### 结构指标(spec §5 表,交付时须报实测数字,**一律 `wc -l` 口径**)
| 指标 | 基线 | 目标 |
|---|---|---|
| `Approve` 行数 | **169** | **< 60** |
| `service/` 包内 >100 行的函数数 | **1** | **0** |
| `service/` 包内最大单函数行数 | **169** | **< 100**(次大者 83,故落点应在 83–100) |
| `moderation.go` 行数 | 298 | **不作指标**(spec D-H:helper 同文件,可能不降反升;报了即可) |
| 既有 `*_test.go` 修改文件数 | — | **0** |
| `architecture_test.go` 修改行数 | — | **0** |
| `Approve` 签名 | `(ctx context.Context, adminID, submissionID, note string) error` | **逐字不变** |
| 字节级比对 `missing` | — | **0** |
> ⚠️ **不要用 `len(text.split('\n'))` 数行数**:文件以换行结尾时它多算一个空段。P14 F5 就因此让控制者报出 83/299 而真值是 82/298,被任务级审查者抓到。`wc -l` 是本仓钉死的口径(P14 spec §5 RE-PIN)。
### mutation 抽查(spec §5-T1 的 a–h,控制者独立复现,不复用实现者结果)
**八条**,每条测"预期红 + 其余绿 + **非编译红** + 恢复证明":
| # | mutation | 预期 |
|---|---|---|
| (a) | helper 存在但 `Approve` 不调它、改为原地展开 | `TestApproveIsSmall` 红 |
| (b) | 构造一个 **>100 行**的 helper(⚠️ spec §5 已警告:把⑥整体 80 行搬进 `applyApprovalInTx` **不会红**,那是阈值 100 的已知盲区,不是守卫失效) | `TestNoOversizedFunction` 红 |
| (c) | 删掉一个 helper(内容合并进 `Approve`) | `TestApproveHelpersExist` 红 |
| (d) | helper 改名(如 `loadApprovePlan`→`loadApproveInputs`) | `TestApproveHelpersExist` 红(**(c) 的对照**:证明钉的是名字集合而非"存在任意 5 个函数") |
| (e) | 扫描范围改成空目录 | 红(防"扫 0 文件报 0 违规") |
| (f) | 阈值 100 → 1000 | 红(防阈值被放宽;断言须自证阈值字面量) |
| (g) | **给 `ModerationService` 加字段**(`clock func() time.Time`) | **P14 断言 6 `TestLineFields` 红**(跨批回归) |
| (h) | **在 `ContentService` 上加方法** | **P14 断言 5 红**,且**须同时测有名与匿名两种接收者形态**(P14 FR-1 教训:只测有名会漏掉 `func (*ContentService) M()`) |
⚠️ **`restore()` 只回滚 mutation 触及的文件,绝不用 `git checkout -- .`**(P14 控制者第 24 次自伤:全量回滚抹掉了未提交的守卫编辑,导致后四轮全在测没有修复的树、汇总报"绿=3")。每轮恢复后核 `git diff HEAD --exit-code -- <file>` 空 + 守卫文件 `func Test` 计数不变。
⚠️ **mutation 若产生编译错误,不算"守卫有牙"**(P14 控制者第 22 次错误:正则截断签名致编译红,却被报成"守卫拦住了遮蔽")。
### 行为不变的额外证据
- **既有 13 个测试函数就是行为守卫**(spec §1.6 表):`TestApproveConcurrentOnlyOneWins`(⑥行锁+重校)· `TestApproveSameWorkIDConcurrent`(④⑥的 `ErrWorkIDTaken`,断言文本含 `want ErrWorkIDTaken or ErrSubmissionConflict`)· `TestApproveMetadataChangeFeaturesSemantics`(⑥ 的 `Features` **三态语义**,用 `meta-absent` 与 `,"features":{}` 两个 payload 精确区分)· `TestApproveOptionalFieldsRoundTrip` · `TestMetadataChangeRuntimeImmutable` · `TestNewVersionMetadataChangeOwnership` · api 层 5 个 + 2 个真库发布链
- **错误文案逐字比对**:`service: load submission: %w` / `service: resolve submitter: %w` / **`service: approve precheck: %w`(两处,只在④)** / `service: copy bundle: %w` / `service: copy cover: %w`;以及 `ErrContentNotFound`(①) vs **`ErrSubmissionConflict`(⑥ 的 `GetByIDForUpdate` 未找到)** 这个**刻意的差异**(spec §3.2 ⑥)
- **两条 slog 逐字**:`slog.Error("approve: pending delete failed", "key", key, "error", err)`(⑦ helper 内)· `slog.Info("approve", "submission", …, "work", …, "kind", …, "admin", …)`(**留在 `Approve` 本体**,spec §3.2 ⑦)
- **`Approve` 只有一个非测试调用点** `handler/admin.go:62`(spec §1.7)→ helper 无须导出;签名变更会打破 `moderationPort` 满足性 → `serve.go` 编译红
---
## 边界(**不做**,spec §4)
- **不合并④⑥的 switch**(spec §1.2 四处实质差异 + 乐观/悲观语义 + 错误文案不同;被否方案 A/B)
- 不动 `Reject`/`Unpublish`/`Republish`/`UpdateWorkFeatures`/`ListQueue`(同文件其它方法)
- 不动 `deleteAccountLocked`(83)/`UpdateSubmission`(76)/`ValidateWorkFields`(75)/`ImportDir`(70)/`Run`(70)/`checkSubmissionRules`(66) —— 六个 >60 行函数各自独立成批(spec §4 挂账)
- 不新增/修改任何测试(既有测试即守卫,spec §2.1 被否方案 D)
- **不改 `architecture_test.go`**(P14 七断言)——若某条因新 helper 变红,**停下来报告**,不自行改守卫
- 不碰 `docs/`、`go.mod`/`go.sum`(实现者红线;记账由控制者做)
- 不碰其它三个仓
---
## 记账(控制者做,实现者禁动 `docs/`)
- `crearte-server/docs/CHANGELOG.md` 新增 **`## [0.19.0] - 2026-10-03`**(插 `## [0.18.0]` 前)
- wrapper `docs/CHANGELOG.md` 新增 **`## [0.3.8] - 2026-10-03`**(插 `## [0.3.7]` 前),小节 `### Done / 完成`;**与 P15-B 合并为一条还是分两条按合并时序定**
- `docs/ROADMAP.md`:第三波表新增 **P15-A 行** + 文档索引表新增一行(**哈希引 merge commit**,P12/P13/P14 惯例)
- **挂账新增**:六个 >60 行函数 · `TestNoOversizedFunction` 阈值 100 的已知盲区(80 行 helper 不被拦)
- 格式:**同条目英文行紧跟中文行、无空行**;不同条目**空一行**;小节标题双语(`### Changed / 变更`、`### Tests / 测试`、`### Context / 背景`)
- **纪律**:显式 `git add <file>`,**禁 `git add -A`**;wrapper 的 `IDENTITY.md`/`SOUL.md`/`USER.md` 三个未跟踪文件**绝不 stage**
- **合并**:CHANGELOG 改动提交到**特性分支** → `git checkout master` → `git merge --no-ff` → 记 merge 哈希 → 推送(**禁管道**)→ `git ls-remote origin master` **非空**对账(`-z` 检查,防空变量假 MATCH)→ 删分支
---
## 审查阶梯(P11–P14 惯例,不可省)
1. **实现者自报** + 证据落盘(RED 输出、四门、mutation、字节级比对)
2. **控制者独立核实**(全部自己跑,不采信自报数字;P14 控制者因此抓到实现者报告可信度高、也抓到自己 6 处错)
3. **任务级审查者**(只读,**自设 mutation 不照抄 spec 的 a–h**,审守卫恒真/恒假、跨任务缝隙、spec 自身问题)
- P13 先例:10 条自设 mutation 挖出 5 条 findings,**M1 抓到控制者自己写错两次的恒真守卫**
- P14 先例:抓到 **spec D-J 的守卫目的无断言覆盖** + **spec 里 positive control 的绕行路径** + 控制者的行数口径错
4. **控制者逐条裁定**(`sdd-pre-merge-review` §3:**未裁定的 note 阻塞合并**,"PASS with notes" 不等于门过了)
5. **全分支终审者**(只读;spec 全文自洽性 + **审控制者的裁定工作** + 独立复现 mutation)
- P11 先例:终审裁定过「**需修复后合并**」→ **不是橡皮章**
- P14 先例:终审在**第一轮修复自身**里找到洞(匿名接收者绕过源码扫描),并**推翻控制者两处已入库的全称声明**
6. **spec re-pin**(裁定后一次性批量改,**勿在审查者读 spec 期间改**=移动靶);**代码改了 spec 必须同步**(P14:正则字面量写进 spec,改代码后 spec 立刻不一致)
@@ -0,0 +1,133 @@
# P15-B 实现计划:bootstrap 加载屏暗色 + 对比度守卫补钉 + 死令牌清理
- **spec(权威)**:`docs/specs/2026-10-03-p15b-bootstrap-dark-design.md`
- **取证账本**:`crearte/.superpowers/sdd-p15/survey.md`(§2/§3/§5/§7/§8 全部实测;含"叉积当违规清单"与"三元互斥分支当同元素共现"两次自我纠错)
- **仓**:`crearte`(**仅此一个**)· **base** `f823195`(master)· 分支 `feat/p15b-bootstrap-dark-and-guard-pins`
> ⚠️ 分支前缀用 `feat/`(AGENTS.md 只允许 `{feat|fix|docs|chore}/`)。本批含用户可感知的暗色改动 → `feat/`;若实现者认为纯守卫部分占主体,**也不得改用 `refactor/`**(不在允许集内)。
> ⚠️ inner repo 红线:**不得在 `master` 上提交**。`git commit` 前先 `git branch --show-current` 确认。
- **性质**:用户可见改动(加载屏暗色)+ 守卫补强 + 死代码清理,**三者一批**(spec D-A…D-H)
- **目标版本**:crearte **0.27.0** · wrapper **0.3.8**
- **派发**:**一个**实现者子代理做完 T1→T4(所有任务同动 crearte 单一工作树与 git index,`sdd-parallel-dispatch` §1 一仓一写者)
- **并行**:P15-A 在 `crearte-server` 仓同时进行 → **不得触碰 `crearte-server/`、`crearte-deploy/`、wrapper 的任何文件**
> spec 与本计划冲突时 **spec 赢**。spec 全文(§0–§8)必读,尤其 **§1.1 的 🔴 架构陷阱(响应式注入会让运行中的游戏被重载)**、§1.3 的连带面表(5 文件)、§1.4(为何不能扩 `noHardcodedColor`)、§2.1 被否方案 A–F、§5-T1 的 10 腿清单 + ⚠️ 恒真审视三条、§8 纪律 12 条(前端 4 条加粗)。
---
## 关键路径约定(与后端不同,勿照搬 P15-A)
- **前端源码与 `package.json` 都在 `crearte/src/`,不在仓根**。所有 `npx`/`npm` 命令须 `cd crearte/src`。(P15 取证期控制者两次因 `cd` 层级写错路径而拿到假结果:`cd` 仓根却写 `app/...`(真根 `src/app/...`)致 grep 全失败输出 `bg-surface=0`;`ls ../e2e` 猜错致空输出。**任何"零命中"结论都要先证明扫描范围非空。**)
- **测试在宿主机跑 `npx vitest run`,不进容器**(P13 plan 第 58 行同款先例:`npx vitest run app/lib/tableOverflow.test.ts app/lib/tableScope.test.ts`)。`crearte/src/node_modules` 已装(232M)。AGENTS.md 的"一切在容器内"针对的是 compose 栈与应用服务,**不针对前端单测**;e2e 需要浏览器,同样在宿主机跑。
- **vitest 环境是 node**(`src/vite.config.ts` 的 `test` 段只有 `exclude`,**无 `environment` 键**)→ 源码级守卫可用 `fileURLToPath(new URL('../..', import.meta.url))` 读文件。**P13 教训:happy-dom 下该写法抛 `ERR_INVALID_URL_SCHEME`**;本批新增守卫**不得引入需要 DOM 的断言**。
---
## 任务表
| 任务 | 交付物 | 要求 | 完成判据 |
|---|---|---|---|
| **T1** | `app/lib/bootstrapTheme.test.ts`(**新增**) | **RED 先行**(spec §5-T1)。**10 腿**:①pre-paint 脚本在 `<style>` 与 `<body>` 之前 ②判定序 hash→prefers→light(`indexOf` 比较,**先断言三者都 `> -1`**)③白名单校验 hash theme(只接受 `light`/`dark`)④IIFE 且只用 `var` ⑤**`GameHost.vue` 注入的是 `effectiveTheme()` 而非 `useTheme().theme.value`** ⑥bootstrap 暗色块六个 hex 与 `main.css` 暗色调色板**逐值一致**(字符串相等,**不得写成 `max(a,b) ≥ k`**)⑦bootstrap 亮色六个 hex **未被改动** ⑧`bootstrap/index.html` 内**不存在 inline `style` 里的 `color:`** ⑨**前提检查**(读到 ≥3 个文件且 bootstrap html 长度 >500)⑩`AA_PAIRS` 含 `['ink','surface']` 且 `REQUIRED_KEYS` 不含 `info`、长度 `toBe(11)` | RED 输出**逐字存证** `.superpowers/sdd-p15b/impl-evidence/t1-red.txt`,并**逐腿标注红/绿**:腿 1/2/5/6/8/10 须红,**腿 7/9 应当已绿**(它们是"不得回退"钉桩,不是新功能断言——spec §5-T1 明写这是有意的) |
| **T2** | `app/lib/contrast.test.ts`(改)+ `app/styles/main.css`(改) | 按 spec §3.2/§3.3:`AA_PAIRS` 插入 `['ink','surface','卡片/表格/表单底(bg-surface 62 处,58 靠 body 继承 text-ink)亮18.42/暗14.85']`(**不得改其它 10 对**);删 `main.css:12`(亮)与`:53`(暗) 两行 `--color-info`;`REQUIRED_KEYS` 删 `'info',`;四处 "12 令牌" 文案改 "11 令牌"(`:47,74,119,121`)。⚠️ **`LEGACY_KEYS`(`:197`)不动**(九键不含 `info`,已实测) | `npx vitest run` **80 文件 / 688+N 全绿**(N = T1 腿数 ≥10);`--color-info` 全仓命中 **0**;`AA_PAIRS` **11** 对;`REQUIRED_KEYS` **11** 项;`LEGACY_KEYS` **9** 项不动 |
| **T3** | `bootstrap/index.html`(改)+ `runtime/host/adapters.ts`(改)+ `runtime/host/GameHost.vue`(改) | 按 spec §3.1(a)–(e):head 内 `<style>` **之前**插 pre-paint 脚本(**逐字对齐 spec 给的代码块**,含 CSP 注释与 `catch` 兜底);`<style>` 加 `html[data-theme="dark"]` 覆盖块(**亮色值逐字不变**,`color-scheme: dark` 随块声明一次);**删两处 inline `color`**(`:25` 的 `#a3a3a3`、`:27` 的 `#f87171`,各保留 `font-size`);`resolveRuntimeTargets` 的 `opts` 加**可选**键 `theme?: 'light' \| 'dark'`,**仅当存在时**才写 `fragment.theme`;`GameHost.vue` 注入 **`effectiveTheme()`** 并写明陷阱注释 | `npx vitest run` 全绿;`npm run typecheck`(`vue-tsc --noEmit`)**零错**;**`adapters.test.ts` 4 处既有调用零修改仍绿**(验证 `theme` 是可选键);既有 `*.test.ts` 修改数 = **1**(仅 `contrast.test.ts`,须在报告显式声明) |
| **T4** | e2e / 产物验证 + mutation 自查 | ⚠️ **RE-PIN 2026-10-03(审查裁定后)**:**偏离 5 已驳回**——端到端腿**可**确定性化,用 **`context.route`(不是 `page.route`)** + inert sw.js(`page.route` 拦不到 SW 注册请求 / SW 发起的请求 / 被 SW `respondWith` 合成的导航,实测命中 0);断言须含 **`snap.url === 父侧 iframe src`**(因果链闭合);另加**行为腿**(游戏页切主题 → iframe `src` 不变,D-B 的唯一行为级防线)与**反方向格**(子侧 `[系统暗色] × [hash=light]` → 期望 `light`,H1/H3 在**子侧直访**路径上的唯一鉴别格;端到端亮色格是第二个鉴别格(RE-PIN 2026-10-04))。产物验证**必须在 e2e 之后重跑生产 `npm run build`** 之上做,并用 `grep -c -F 'localhost:4173'` = **0** 证明量的是生产构建(控制者当日量了 e2e 残留 → 7069 vs 7056 的假矛盾)。计数须**同时给 raw 与屏蔽注释后两个数**、字节用 `wc -c`(详见 spec §5-T4.3/§5-T4.4 与 `adjudication.md` §三) | **端到端腿 + 行为腿 + 反方向格三类都跑绿**;mutation 复验须跑审查者自设的 **G1/G2/H1/H3/F1/F2/H4 七条**(spec 的 (a)–(j) 对这七条**全部无牙**)+ 全部对照组 + 合法树 |
---
## 验收(控制者独立复跑,不采信自报)
### 测试与构建
```bash
cd crearte/src
npx vitest run # 基线 79 文件 / 688 测试 → 目标 80 / 688+N
npm run typecheck # vue-tsc --noEmit,零错
npm run build # 含 vue-tsc + vite build + build-runtime.mjs
npm run e2e # 基线 102+1skip(dark.spec.ts 10 腿)
```
基线(spec §0,已实测 2026-10-03 13:07):**vitest 79 文件 / 688 测试全绿,Duration 44.83s**——与 P13 交付值逐字相同(P14 未触及前端)。**e2e 102+1skip** 是 P13 交付值。
⚠️ `npm run build` 含 `vue-tsc --noEmit`,会**类型检查 `e2e/*.spec.ts`**;spike 期间若只想快速验产物可用 `npx vite build`(P13 纪律 5 的同款做法),但**交付验收必须跑完整 `npm run build`**。
### 结构指标(spec §5 表,交付时须报实测数字)
| 指标 | 基线 | 目标 |
|---|---|---|
| vitest 文件数 / 测试数 | **79 / 688** | **80 / 688+N**(N ≥ 10) |
| 既有 `*.test.ts` 修改文件数 | — | **1**(仅 `contrast.test.ts`,仅因 "12→11 令牌" 文案;**须在报告显式声明**) |
| `AA_PAIRS` 对数 | 10 | **11** |
| `REQUIRED_KEYS` 项数 | 12 | **11** |
| `LEGACY_KEYS` 项数 | 9 | **9**(不动) |
| `--color-info` 全仓命中 | 2(两处定义) | **0** |
| bootstrap 内 `localStorage` / `prefers-color-scheme` / `data-theme` 命中 | **0 / 0 / 0** | **0 / ≥1 / ≥1**(`localStorage` **仍须为 0**:源隔离,spec D-A) |
| bootstrap 内 inline `style` 的 `color:` 数 | 2 | **0** |
| `typecheck` / `build` | 0 / 0 | **0 / 0** |
| 主 e2e | 102+1skip | **≥102+1skip**(新增腿另计) |
### mutation 抽查(spec §5-T1 的 (a)–(j),控制者独立复现,不复用实现者结果)
**十条**,每条测"预期红 + 其余绿 + 恢复证明":
| # | mutation | 预期 |
|---|---|---|
| (a) | bootstrap 的 pre-paint `<script>` 移到 `<style>` 之后 | 腿 1 红 |
| (b) | 交换 hash theme 与 `prefers-color-scheme` 的判定先后 | 腿 2 红 |
| (c) | hash theme 校验放宽成 `hashTheme ? hashTheme : …` | 腿 3 红 |
| (d) | **`GameHost.vue` 改用 `useTheme().theme.value`** | 腿 5 红(**这条是 spec D-B 的牙**:响应式注入会让主题切换重载运行中的游戏) |
| (e) | 改 bootstrap 暗色的一个 hex | 腿 6 红 |
| (f) | 加回 `style="color:#a3a3a3"` | 腿 8 红 |
| (g) | 从 `AA_PAIRS` 删掉补钉的 `['ink','surface']` | 腿 10 红 |
| (h) | 把 `info` 加回 `REQUIRED_KEYS` | 腿 10 红 |
| (i) | **守卫的文件路径改成不存在的文件** | 腿 9 红(防"读 0 文件报 0 违规"的假信心) |
| (j) | **删掉 `main.css` 的暗色块**(模拟 P13 成果回退) | 腿 6 或 `contrast.test.ts` 的暗色腿红 |
⚠️ **`restore()` 只回滚 mutation 触及的文件,绝不用 `git checkout -- .`**(P14 控制者第 24 次自伤:全量回滚抹掉了未提交的守卫编辑,导致后四轮全在测没有修复的树、汇总报"绿=3")。每轮恢复后核 `git diff HEAD --exit-code -- <file>` 空 + 守卫文件腿数不变。
⚠️ **写守卫时注释里不要出现完整的 `bg-[#…]` / `text-[#…]` 字面量**(spec §8-8:**Tailwind v4 扫描全部源文件含 `.test.ts`**,注释里的类名字面量会被当候选、烧死 utility 进产物。P13 实现者用拼接修对一处,控制者八分钟后在自己裁定 commit 里重新引入,靠重建抓出)。
### 产物验证(P13 教训:源码级守卫绿 ≠ 产物正确)
- **必须验真实 `dist/`**,不得用运行时注入或 `APPLY.toString()`+`new Function` 序列化注入替代(P13 控制者勘查期两次栽在这两种无效手法上)
- 选择器/字符串 grep **用 `grep -F`**,不手写反斜杠转义(P13:`.backdrop\:bg-scrim` 手写转义对 minified 产物假阴性)
- **带引号的 grep 模式对 minified 产物也假阴性**(`grep 'bg-[#]'` 匹配不到压缩形态)
---
## 边界(**不做**,spec §4)
- **不动 `themeBootstrap.test.ts`**(主应用 pre-paint 守卫 7 腿)——若某腿因本批变红,**停下来报告**
- **不扩 `noHardcodedColor.test.ts` 的扫描根**(spec §1.4 + 被否方案 D:`candidates()` 只收 `.vue`、`maskNonTemplate()` 会空白掉 `<style>` 块 → 扩根**扫不到任何东西**,只会制造"已覆盖"的假信心)
- **不动 `useTheme.ts`**(`effectiveTheme()` 已够用;改它会波及主应用 10 腿 e2e)
- **不动 `src/index.html`**(主应用入口,P13 交付)
- **不改 P13 的 CHANGELOG 0.26.0 条目与 P13 spec 原文**(spec D-C:改写会让"当时交付了什么"失真;改为新条目说明 + P13 spec 加 ⚠️ RE-PIN 标注块,那是控制者的记账动作)
- **不修 GameHost 亮色徽标 WCAG 3.26**(spec §4 挂账 + 被否方案 F:属**设计决策**,显而易见的一行修法已实测证伪——亮色 `accent` vs `accent-ink` 仅 **1.4943**,徽标与「加载失败」状态点同屏共存,改完会把两个语义压成一个视觉信号;ROADMAP `9965fd7` 已登记该约束)
- **不做第三态"恢复跟随系统"**(P13 spike 3 已证伪:header 在 @320px 最坏格无像素容纳带标签控件)
- 不新增任何令牌(只删 `--color-info`)
- 不碰 `crearte-server`、`crearte-deploy`、wrapper 的 `docs/`(实现者红线;记账由控制者做)
---
## 记账(控制者做,实现者禁动 `docs/`)
- `crearte/docs/CHANGELOG.md` 新增 **`## [0.27.0] - 2026-10-03`**(插 `## [0.26.0]` 前)
- wrapper `docs/CHANGELOG.md` 新增 **`## [0.3.8] - 2026-10-03`**(插 `## [0.3.7]` 前),小节 `### Done / 完成`;**与 P15-A 合并为一条还是分两条按合并时序定**
- `docs/ROADMAP.md`:第三波表新增 **P15-B 行** + 文档索引表新增一行(**哈希引 merge commit**);挂账里**删掉「死令牌 `--color-info`」**(已处置)、**新增「bootstrap 内联脚本的 CSP nonce」**(与主应用同批)
- P13 spec 加 **⚠️ RE-PIN 标注块**(spec D-C:12→11 令牌),**不改写原文**
- 格式:**同条目英文行紧跟中文行、无空行**;不同条目**空一行**;小节标题双语
- **纪律**:显式 `git add <file>`,**禁 `git add -A`**;wrapper 的 `IDENTITY.md`/`SOUL.md`/`USER.md` 三个未跟踪文件**绝不 stage**
- **合并**:CHANGELOG 改动提交到**特性分支** → `git checkout master` → `git merge --no-ff` → 记 merge 哈希 → 推送(**禁管道**)→ `git ls-remote origin master` **非空**对账(`-z` 检查,防空变量假 MATCH)→ 删分支
---
## 审查阶梯(P11–P14 惯例,不可省)
1. **实现者自报** + 证据落盘(RED 逐腿标注、mutation 十条、产物验证)
2. **控制者独立核实**(全部自己跑,不采信自报数字)
3. **任务级审查者**(只读,**自设 mutation 不照抄 spec 的 (a)–(j)**,审守卫恒真/恒假、spec 自身问题、**特别是腿 5 这条架构陷阱断言是否真能防住响应式注入**)
- P13 先例:10 条自设 mutation 挖出 5 条 findings,**M1 抓到控制者自己写错两次的恒真守卫**(`max(a,b) ≥ 3` 互补下界 4.0621)
- P14 先例:抓到 **spec D-J 的守卫目的无断言覆盖** + **spec 里 positive control 的绕行路径** + 控制者行数口径错(`split` vs `wc -l`)
4. **控制者逐条裁定**(`sdd-pre-merge-review` §3:**未裁定的 note 阻塞合并**,"PASS with notes" 不等于门过了)
5. **全分支终审者**(只读;spec 全文自洽性 + **审控制者的裁定工作** + 独立复现 mutation)
- P11 先例:终审裁定过「**需修复后合并**」→ **不是橡皮章**
- P14 先例:终审在**第一轮修复自身**里找到洞(匿名接收者 `func (*ContentService) M()` 绕过源码扫描),并**推翻控制者两处已入库的全称声明**
6. **spec re-pin**(裁定后一次性批量改,**勿在审查者读 spec 期间改**=移动靶);**代码改了 spec 必须同步**(P14:正则字面量写进 spec,改代码后 spec 立刻不一致,构成"需修复后合并"的同类形态)
@@ -0,0 +1,321 @@
# P9-B UX 第二批设计:可访问与键盘效率(a11y / keyboard)
日期:2026-10-01 | 决策链:owner UX 摸底三包(A 浏览器反馈 / B 效率可访问 / C 暗色模式)——A 已作 P9 第一批交付(crearte 0.22.0),第一梯队日常交互包已作 P10 交付(crearte 0.23.0)。本批 = 摸底 **B 包**,并收口 P10 终审转办的 T1(d)(实时区内嵌交互控件)。总纲仍为「继续增强该项目,从用户体验方面」。
仓库面:纯 `crearte`(前端)。零后端/部署改动。
## §1 现状勘查结论(已实测,不是推测)
已经就位、本批**不动**的面(避免重复劳动与无谓回归):
- `:focus-visible { outline: 3px solid var(--color-accent); outline-offset: 2px }` 全局焦点环已在 `main.css:46`,`accent` 对 paper 3.26 / 对 surface 3.64,满足 WCAG 1.4.11 非文本对比 3:1。
- `prefers-reduced-motion: reduce` 全局归零分支已在 `main.css:95-101`,覆盖 toast/lift/骨架动画。
- 播放 iframe 已有 `:title="game.name"`(`GameHost.vue:105`);封面 `<img :alt="game.name">`(`GameCover.vue`);投稿封面预览 `alt="封面预览"`。
- 页头三条主导航已有 `:aria-current="onX ? 'page' : undefined"`。
- `FilterDrawer` 用原生 `<dialog>.showModal()`,焦点圈闭与 Esc 关闭由浏览器负责;关闭按钮有 `aria-label="关闭筛选"` 且 44px 触达面(`min-h-11 min-w-11`)。
- `BaseSelect` 自绘 listbox 的键盘面已完整:trigger 上 ArrowDown/Up/Enter/Space 开面板,面板内 Esc 关并回焦、Arrow 移动、Enter/Space 选中、Tab 关不回焦,`role=listbox`/`role=option`/`aria-selected`/`aria-expanded`/`roving tabindex` 齐备。
- 表单控件均有可见或 `sr-only` 的 `<label for>`(CatalogView / AdminUsersView 搜索、SubmitFormView 全字段)。
- 登录/注册/账号三页的错误提示已做 `id` + `:aria-describedby` 字段级关联。
- 收藏按钮已有 `:aria-pressed="favorited"`,心形字形已 `aria-hidden`。
- **移动端页头无溢出**:实测 360px 与 320px 视口下 `documentElement.scrollWidth` 恰等于视口宽(320/360),页头内层 `scrollWidth` 亦相等。故本批**不引入汉堡菜单**——没有需要修的问题,加菜单只会增加键盘陷阱面。
实测出的真实缺口(本批范围):
1. `--color-success: #2fa46a` 对 paper 仅 **2.83**,对 surface 3.16 —— 作为成功 toast 的底色配 `text-paper`(12px 粗体,非大字号)实测 **3.16**,AA 正文需 4.5,**FAIL**。
2. 错误 toast 用 `bg-accent`(#e8552f)配 `text-paper` 实测 **3.64**,**FAIL**(AA 正文需 4.5)。
3. 三个管理视图共 **24 个 `<th>` 元素零 `scope`**(`grep -c '<th'` 的行计数为 19——AdminView 三张表把多个 `<th>` 塞在同一 `<tr>` 行内,故行数 < 元素数;以词界 `<th\b` 的元素计数 24 为准),且两个「操作」列的表头是空 `<th class="px-3 py-2" />`(无可访问名);全仓 `scope="row"` 出现 0 次。屏幕阅读器读表格时无法把单元格与列头关联。
4. 五星评分按钮的可访问名就是字形本身「★」/「☆」——读屏逐个念「星 星 星」,无法知道这是几分、当前评了几分、点了会怎样。
5. 全站**无 skip-link**:键盘用户每个页面都要 Tab 过页头(logo + 3 条导航 + 贴纸 + 登录/用户菜单 4 项)才能到正文。
6. SPA 导航后**无焦点管理**:`router` 的 `afterEach` 只设 title/description,焦点留在旧位置(通常是 body),读屏用户点链接后听不到任何新页面上下文。
7. (P10 T1(d) 转办)toast 容器整体是 `role="status" aria-live="polite"`,而每条 toast 内含可交互的「关闭提示」按钮——实时区内嵌交互控件是公认反模式:读屏会在轮询实时区时把按钮一并播报,且用户无法可靠地把焦点停在该控件上。
## §2 决策表
| # | 决策点 | 定案 | 理由 / 放弃的替代 |
|---|---|---|---|
| D-A | `success` 色令牌加深 | `--color-success: #1f7a4d`(`text-paper` 在其上 **4.76**、白 **5.32**;作文字色对 paper 4.76 / 对 surface 5.32) | 唯一消费者是成功 toast 底色,改令牌即全站生效且不留分叉。放弃「只改 toast 用别的绿」——会让令牌与实际用色脱节,下一个消费者重踩。放弃 #186039(6.79):过暗,脱离新粗野主义的高饱和海报感。 |
| D-B | 错误 toast 底色 | `error: 'bg-accent-ink text-paper'`(**4.87**) | `accent-ink` 已是既有令牌且已被 AdminUsersView 用作 `bg-accent-ink text-paper` 底色,复用即一致,不新增色。放弃新增 `--color-error` 令牌:与 `accent-ink` 同值同义,纯冗余。放弃给错误 toast 加白字:`text-paper` 本就是纸白,无需动。 |
| D-C | 对比度守卫测试 | 新增 `app/lib/contrast.test.ts`:解析 `main.css` 的 `@theme` 令牌,按 WCAG 2.1 相对亮度算比值,钉死关键配色对 ≥4.5(焦点环 ≥3) | 仿既有 `app/lib/no-gradient.test.ts` 的源码级守卫范式,让「不许再引入低对比配色」成为可执行约束而非口头纪律。放弃逐组件视觉回归:成本高且测不到令牌层。 |
| D-D | skip-link | `App.vue` 根 div 首子元素插 `<a href="#main" class="skip-link …">跳到主内容</a>`;`<main id="main" tabindex="-1">`;样式走 Tailwind `sr-only` + `focus:not-sr-only` 组合,**不新增 CSS 规则** | 原生锚点跳转会同时移动焦点(href 指向带 tabindex=-1 的 main),无需 JS。`focus:z-[80]` 压过 toast 的 `z-[70]`。放弃自绘 `.skip-link` CSS:工具类已足够,少一处需维护的样式。 |
| D-E | 路由后焦点管理 | `attachTitleHook` 的 `afterEach` 改为 `(to, from)`,当 `to.path !== from.path` 时对 `#main` 调 `focus({ preventScroll: true })`;`#main` 不存在时静默 no-op | 只在**路径**变化时移焦:`/games → /games?tag=数字`(P10 T3 的标签点击)属同页筛选,抢焦点会打断用户;路径变化才是真正的「换页」。`preventScroll` 因为 `scrollBehavior` 已负责滚动,双重滚动会抖。放弃聚焦各页 h1:17 个视图有 2 个含双 h1(GameView / OutboundView 的 notFound 分支),聚焦目标不唯一,且 h1 加 tabindex 会污染 Tab 序。 |
| D-F | 表格列头语义 | 三个管理视图全部 `<th>` 加 `scope="col"`(元素级 24 个:AdminView 13 + AdminUsersView 6 + AdminAuditView 5);两个空的操作列表头改为 `<th scope="col" class="px-3 py-2"><span class="sr-only">操作</span></th>` | `scope="col"` 是最小且正确的修复。**不**改 `<td>` → `<th scope="row">`:那要把每行首格的排版结构(含 `<span>` 嵌套、font-bold 混排)重写成表头单元格,视觉回归风险大而收益有限(列头关联已解决读屏的主要痛点)。空表头补 sr-only 文本,否则操作列在读屏里是「无名第 5 列」。 |
| D-G | 表格守卫测试 | 新增 `app/lib/tableScope.test.ts`:扫描 `app/**/*.vue`,断言每个 `<th` 都带 `scope=` | 与 D-C 同为源码级守卫,覆盖现有 5 张表与将来新增的表,比逐视图单测便宜且不随表格增删失效。放弃只测两个有单测的视图:AdminView 无测试文件,逐视图补测是 3 份重复劳动。 |
| D-H | 五星评分可访问名 | 分组 `<span role="group" :aria-label="rated ? \`评分:${score} 星\` : '评分'">`;每颗星 `:aria-label="\`评 ${i} 星\`"`,内层字形 `<span aria-hidden="true">` | `aria-label` 覆盖字形成为可访问名,读屏念「评 3 星」而非「星」。分组标签携带当前值(已评几分),补齐「点了会怎样/现在是什么」。**不**用 `radiogroup`/`radio` + `aria-checked`:本控件支持「点当前分 = 撤评」,radio 无法表达取消选中;也**不**用逐星 `aria-pressed`——点亮是 `i <= litStars` 的连续区间,逐星 pressed 会把「4 分」播报成「1、2、3、4 星都按下」,语义反而更糊。 |
| D-I | toast 实时区重构(收口 P10 T1(d))**D-I′ re-pin,见 §3.5** | 容器与每条 toast 均**不带**任何播报语义;另设一个**常驻**的 `sr-only` 播报区 `<p role="status" aria-live="polite">`,其文字在挂载后由 watch 驱动变更 | 播报区必须**先存在**再变更文字,读屏才会播报。初稿方案(把 `role=status` 挂在每条 toast 的文本 `<p>` 上)被 T1 实现者指出为缺陷:**播报区与文字同时创建**,在多个读屏+浏览器组合下不播报首条——等于把「按钮噪音」问题换成「彻底静默」问题。改为 Radix Toast / Headless UI 等无障碍组件库的标准做法:视觉层与播报层解耦。放弃「容器保留 role、按钮 aria-hidden」:按钮对读屏消失,鼠标用户能关、读屏用户关不掉,更糟。放弃「每条 toast 各自 role=status」:即初稿,见上。 |
| D-J | e2e 覆盖 | 新增 `e2e/a11y.spec.ts` 三条:① Tab 首站是 skip-link、回车后焦点落 `#main`;② `/admin/users` 的 columnheader 可访问名齐备(含「操作」);③ 作品页 star 按钮可访问名为「评 N 星」 | skip-link 是纯键盘时序行为,happy-dom 单测测不出真实 Tab 序;管理页需登录态(`helpers.seedSession(page,'admin')`)。既有 e2e 文件一字不动,只新增。 |
| D-K | 移动端页头 | **本批不做**(实测无溢出,见 §1) | 没有问题就不修。引入汉堡菜单会新增焦点圈闭、Esc、aria-expanded 三处需维护的状态机,纯负收益。 |
## §3 设计细节(实现须逐字采用)
### 3.1 D-A / D-B / D-C:色彩对比
`app/styles/main.css` 的 `@theme` 块内,唯一改动:
```css
--color-success: #1f7a4d;
```
`app/components/ToastHost.vue` 的 `KIND_CLASS`,唯一改动(`error` 行):
```ts
const KIND_CLASS: Record<ToastKind, string> = {
success: 'bg-success text-paper',
error: 'bg-accent-ink text-paper',
info: 'bg-highlight text-ink'
}
```
新增 `app/lib/contrast.test.ts`:从 `main.css` 正则提取 `--color-<name>: #rrggbb`,实现 WCAG 相对亮度与对比度,断言下列配对(`fg on bg`):
- ≥ 4.5:`paper on success`、`paper on accent-ink`、`ink on highlight`、`ink on accent`、`ink-soft on paper`、`ink-faint on paper`、`ink-soft on surface`、`ink-faint on surface`、`paper on ink`、`accent-ink on paper`
- ≥ 3:`accent on paper`(焦点环,1.4.11 非文本)
亮度公式(逐字,别自创):
```ts
function channel(c: number): number {
const s = c / 255
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
}
function luminance(hex: string): number {
const h = hex.replace('#', '')
const [r, g, b] = [0, 2, 4].map((i) => channel(parseInt(h.slice(i, i + 2), 16)))
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
export function contrast(a: string, b: string): number {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x)
return (hi + 0.05) / (lo + 0.05)
}
```
### 3.2 D-D / D-E:skip-link 与路由后焦点
`app/App.vue` 模板(`<AppHeader />` 之前插 skip-link,`<main>` 加 `id` 与 `tabindex`):
```html
<template>
<div class="flex min-h-screen flex-col">
<a
href="#main"
class="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[80] focus:border-2 focus:border-ink focus:bg-highlight focus:px-3 focus:py-2 focus:text-sm focus:font-extrabold focus:shadow-hard"
>跳到主内容</a>
<AppHeader />
<main id="main" tabindex="-1" class="mx-auto flex w-full max-w-6xl flex-1 flex-col px-4 py-6">
<RouterView />
</main>
<AppFooter />
<ToastHost />
</div>
</template>
```
`app/router/index.ts`:新增导出函数 + 改 `attachTitleHook` 签名内的回调:
```ts
// SPA 导航后把焦点交给主内容区:读屏用户据此获得新页面上下文(D-E)。
// 仅路径变化时移动——同路径改 query(如目录 /games?tag=x 筛选)不打断用户焦点。
// preventScroll:滚动由 router.scrollBehavior 负责,避免双重滚动抖动。
export function focusMain(): void {
document.getElementById('main')?.focus({ preventScroll: true })
}
export function attachTitleHook(r: Router): void {
r.afterEach((to, from) => {
setPageTitle(joinTitle(sectionTitleOf(to.name)))
setPageDescription('')
if (to.path !== from.path) focusMain()
})
}
```
注意:`to.path !== from.path` 在首次导航时 `from` 是 `START_LOCATION`(path `/`),从 `/` 进 `/` 不会误触;从 `/` 进 `/games` 会正确触发。`#main` 尚未挂载(router 早于 app mount 就绪的边界)时 `getElementById` 返回 null,可选链静默 no-op。
### 3.3 D-F / D-G:表格列头
三个文件的每个 `<th>` 加 `scope="col"`,类名与文本一字不动。两处空表头(`AdminView.vue:152` 队列表的 `<th class="px-3 py-2" />`、`AdminUsersView.vue:123` 的 `<th class="px-3 py-2" />`)改为:
```html
<th scope="col" class="px-3 py-2"><span class="sr-only">操作</span></th>
```
新增 `app/lib/tableScope.test.ts`:递归扫 `app/**/*.vue`(排除 `*.test.ts`),对每行匹配 `<th\b`,断言该 `<th` 起始标签内含 `scope=`。多行书写的 `<th` 需读到闭合 `>` 为止再判定(AdminUsersView 的空表头是单行,但守卫要对将来健壮)。违规时报告 `相对路径:行号: 行内容`,断言 `toEqual([])`——与 `no-gradient.test.ts` 同风格。
### 3.4 D-H:五星评分
`app/components/GameReactions.vue` 的星标分组:
```html
<span
class="inline-flex items-center"
role="group"
:aria-label="rated ? `评分:${score} 星` : '评分'"
>
<button
v-for="i in 5"
:key="i"
type="button"
:data-testid="`star-${i}`"
:aria-label="`评 ${i} 星`"
:disabled="busy"
class="px-0.5 font-mono text-base leading-none disabled:opacity-60"
@click="pickStar(i)"
><span aria-hidden="true" :class="i <= litStars ? 'text-accent-ink' : 'text-ink-soft'">{{ i <= litStars ? '★' : '☆' }}</span></button>
</span>
```
`data-testid`、`disabled`、类名、点击语义(点当前分撤评)全部不动;既有测试对 `.text()` 的断言不受影响(`aria-label` 不改文本内容)。
### 3.5 D-I:toast 实时区(D-I′ re-pin)
**为什么 re-pin**:初稿把 `role="status"` 挂在每条 toast 的文本 `<p>` 上。播报区与文字**同时被创建**——屏幕阅读器只可靠播报「已存在于页面上的播报区」内的变更,新出现的播报区在多个读屏+浏览器组合下不播报首条,结果是把「按钮噪音」换成了「彻底静默」。修正为视觉层与播报层解耦:常驻一个 `sr-only` 播报区,文字在挂载**之后**由 watch 驱动变更。
`app/components/ToastHost.vue` 全文(`<script setup>` + 模板):
```html
<script setup lang="ts">
import { ref, watch } from 'vue'
import { PhX } from '@phosphor-icons/vue'
import { useToast, type ToastKind } from '@/composables/useToast'
const { toasts, dismiss } = useToast()
// 各提示类型配色(新粗野主义:实底 + 描边 + 硬阴影)
const KIND_CLASS: Record<ToastKind, string> = {
success: 'bg-success text-paper',
error: 'bg-accent-ink text-paper',
info: 'bg-highlight text-ink'
}
// 常驻播报区(D-I′):屏幕阅读器只播报「已存在于页面上的 live region」内的变更,
// 故播报节点必须随组件挂载即存在、之后只改文字。可见 toast 不带任何播报语义,
// 使「关闭提示」按钮不再是 live region 的后代(P10 T1(d) 收口)。
// 取 toasts 末尾一条即最新推入者(useToast 把新条 append 到尾部)。
const announcement = ref('')
watch(
() => toasts.value.length,
(n) => {
announcement.value = n > 0 ? (toasts.value[n - 1]?.text ?? '') : ''
}
)
</script>
<template>
<!-- 不 Teleport:容器常驻,屏幕阅读器语义稳定 -->
<div
data-testid="toast-host"
class="pointer-events-none fixed bottom-4 right-4 z-[70] flex w-72 flex-col gap-2"
>
<!-- 播报层:sr-only 常驻节点,只由 watch 改文字,不含任何交互控件 -->
<p data-testid="toast-announce" role="status" aria-live="polite" class="sr-only">{{ announcement }}</p>
<TransitionGroup name="toast">
<div
v-for="t in toasts"
:key="t.id"
data-testid="toast"
:class="[
'pointer-events-auto flex items-start justify-between gap-2 border-2 border-ink px-3 py-2 text-xs font-bold shadow-hard',
KIND_CLASS[t.kind]
]"
>
<span class="min-w-0 flex-1">{{ t.text }}</span>
<button
type="button"
aria-label="关闭提示"
class="-mr-1 shrink-0 cursor-pointer"
@click="dismiss(t.id)"
>
<PhX :size="12" weight="bold" aria-hidden="true" />
</button>
</div>
</TransitionGroup>
</div>
</template>
```
要点:
- 播报节点 `data-testid="toast-announce"` 在容器**内首子位置**、`TransitionGroup` 之外,故它不参与进出场动画,也不被 `v-for` 重建。
- watch 只观察 `toasts.value.length`:新消息推入时长度必增(上限 3 条时 `push` 会同时移最旧再 append,长度仍是 3→3,但此时 `toasts.value[n-1]` 已换成新条——见下方陷阱)。
- 可见 toast 的文本节点用 `<span class="min-w-0 flex-1">`(不是 `<p>`):它在 `<div data-testid="toast">` 内,用块级 `<p>` 也可以,但 span 保持与 P10 原版一致的最小结构变更;`min-w-0 flex-1` 负责长文案换行时按钮仍右贴。
**陷阱**:`MAX_VISIBLE=3` 且已满 3 条时,`push` 的实现是 `toasts.value = [...slice(-(MAX_VISIBLE-1)), newItem]`——长度 3→3 不变,只观察 `length` 的 watch **不会触发**,第 4 条消息将不被播报。故 watch 源必须是**末尾元素的 id** 而非长度:
```ts
watch(
() => toasts.value[toasts.value.length - 1]?.id,
(id) => {
announcement.value = id === undefined ? '' : (toasts.value[toasts.value.length - 1]?.text ?? '')
}
)
```
`id` 由 `useToast` 单调递增(`nextId++`),故「有新消息」当且仅当「末尾 id 变化」,饱和态同样成立。全部 toast 消失时末尾 id 为 `undefined`,播报文字清空。
`useToast.ts` 一字不动(store 语义与本批无关)。
### 3.6 D-J:e2e
新增 `src/e2e/a11y.spec.ts`,三条用例,既有 e2e 文件与 `helpers.ts` 一字不动:
```ts
import { expect, test } from '@playwright/test'
import { seedSession } from './helpers'
test('skip-link:Tab 首站可见,回车后焦点落在主内容区', async ({ page }) => {
await page.goto('http://localhost:4173/')
await page.keyboard.press('Tab')
const skip = page.getByRole('link', { name: '跳到主内容' })
await expect(skip).toBeVisible()
await expect(skip).toBeFocused()
await page.keyboard.press('Enter')
await expect(page.locator('main#main')).toBeFocused()
})
test('管理端表格列头具备可访问名(含 sr-only 的操作列)', async ({ page }) => {
await seedSession(page, 'admin')
await page.goto('http://localhost:4173/admin/users')
const headers = page.getByRole('columnheader')
await expect(headers).toHaveCount(6)
await expect(headers.nth(0)).toHaveAccessibleName('用户名')
await expect(headers.nth(5)).toHaveAccessibleName('操作')
})
test('评分按钮的可访问名是「评 N 星」而非字形', async ({ page }) => {
await seedSession(page, 'user')
await page.goto('http://localhost:4173/games/fixture/2048')
await expect(page.getByTestId('star-3')).toHaveAccessibleName('评 3 星')
await expect(page.locator('[role=group][aria-label^="评分"]')).toHaveCount(1)
})
```
## §4 边界与不做的事
- 禁触:`e2e/landing.spec.ts`、`e2e/noauth.spec.ts`、`e2e/author-page.noauth.spec.ts`、`e2e/merge-repo.spec.ts`、`e2e/ux.spec.ts`、`e2e/submit-flow.spec.ts`、`e2e/admin-flow.spec.ts`、`e2e/helpers.ts`、`playwright.config.ts`、`playwright.noauth.config.ts`、`vitest.config.ts`、`vite.config*.ts`、`scripts/`、`package.json`。
- `e2e/ux.spec.ts`(禁触)靠 `[data-testid=toast]` 定位并断言 `toContainText('链接已复制')`——`data-testid="toast"` 与可见文本内容必须原样保留(文本搬进 `<span>` 后 `toContainText` 仍成立,子孙文本匹配)。
- `runtime/` 本批**完全不动**(含 GameHost)——播放区可访问性(iframe title、全屏按钮语义)已在既有代码达标。
- 不新增依赖、不改 `version`(恒 0.1.0)、不动 CHANGELOG(控制者统一写)。
- 不改任何视觉设计:平面海报新粗野主义(无渐变/无圆角/硬阴影)必须保持,`no-gradient.test.ts` 守卫须继续绿。除 D-A/D-B 两处配色外,不动任何既有颜色与排版。
- 不做移动端汉堡菜单(D-K,实测无溢出)。
- 不做 `<td>` → `<th scope="row">` 的行头重构(D-F 理由)。
- 不做暗色模式(C 包,另行立项)。
## §5 测试计划
vitest(新增/扩展):
- 新 `app/lib/contrast.test.ts`:令牌解析成功(能取到 paper/ink/success/accent-ink/highlight/accent/ink-soft/ink-faint/surface);§3.1 列出的 ≥4.5 配对逐条断言;焦点环 `accent on paper` ≥3。
- 新 `app/lib/tableScope.test.ts`:守卫断言 `violations === []`(即全仓每个 `<th` 都带 scope)。
- 扩 `app/components/ToastHost.test.ts`:① 容器与每条 toast 均**不含** `role`/`aria-live`(断言属性不存在);② 播报节点 `[data-testid=toast-announce][role=status][aria-live=polite]` 在 store 为空时**已存在**(常驻前提)且文字为空;③ push 后播报文字等于该消息;④ **饱和态播报**(D-I′ 陷阱钉桩):连推 4 条,第 4 条时 store 仍是 3 条(最旧被逐出)而播报文字必须是第 4 条的文本;⑤ 关闭按钮不是播报节点的后代,且播报节点内不含任何 `button`;⑥ kind 配色断言中 `error` 由 `bg-accent` 改 `bg-accent-ink`;⑦ 既有的「点关闭 → store 少一条」保留。
- 扩 `app/router/index.test.ts`:新 describe「SPA 导航后焦点(D-E)」——先在 `document.body` 注入 `<main id="main" tabindex="-1">`,push `/` → `/games` 后断言 `document.activeElement.id === 'main'`;`/games` → `/games?tag=x` 后断言焦点**未**被 main 抢走(先 blur 或先聚焦别的元素再验);`#main` 不存在时 push 不抛错。
- 扩 `app/components/GameReactions.test.ts`:star-1..5 的 `aria-label` 为「评 N 星」;分组 `role=group` 且未评时 `aria-label === '评分'`、已评(`rated: true, score: 4`)时为「评分:4 星」;内层字形 `aria-hidden="true"`;既有 `.text()` 为 ★/☆ 的断言全部保留且仍绿。
e2e:
- 新 `e2e/a11y.spec.ts` 三条(§3.6 逐字)。
- 既有全部 e2e 文件一字不动,且必须继续全绿(`landing.spec.ts` 的 `getByRole('heading', { level: 1 })`、`locator('main')`、`ux.spec.ts` 的 `locator('main').click({position:{x:5,y:5}})` 与 toast 断言均须不受影响——skip-link 在 main 之外、toast 的 `data-testid` 与文本内容不变)。
验收腿:vitest 全绿(基线 601 + 新增)、`vue-tsc` 零错、`npm run build` OK、主 e2e(基线 77+1skip + 新增 3)、noauth 4。
## §6 CHANGELOG 口径(控制者写)
版本 `0.24.0`。Added:skip-link、路由后焦点管理、表格列头语义、评分可访问名、对比度/表格守卫测试、a11y e2e。Changed:success 令牌加深、错误 toast 底色换 accent-ink、toast 播报改为常驻 sr-only 播报区(视觉层与播报层解耦,交互控件移出播报区)。Tests:数字以实跑为准。中英双语,同条目英中相邻行、条目间空行。
@@ -0,0 +1,290 @@
# P11 服务端安全硬化批 — 设计文档
日期:2026-10-02 | 涉及仓:`crearte-server`(主)、`crearte-deploy`(编排)、`crearte`(仅 nginx 模板)| 分支:`fix/p11-server-hardening`(server)/ `feat/p11-trusted-proxies`(deploy、crearte)
## 1. 问题(三处实测缺陷,全部有 spike 证据)
### 缺口 A:限流桶全站塌缩为单桶(高危)
`crearte-server` 的六个限流器实例全部以 `ctx.ClientIP()` 为键(`internal/api/ratelimit.go:42`)。gin 的 `ClientIP()` 只有在 **peer 本身落在 `SetTrustedProxies` 信任列表内**时才会解析 `X-Forwarded-For`;否则忽略该头、返回 peer IP。
部署拓扑事实:
- `api-prod` / `api-dev` **都不发布宿主端口**,唯一入站路径是 nginx(prod)或 vite(dev)反代。
- nginx 设 `X-Forwarded-For $proxy_add_x_forwarded_for`(`crearte/deploy/nginx.conf.template`,两个 server 块各一处),于是 API 看到的 peer 恒为 nginx 容器 IP。
- `TRUSTED_PROXIES` 在 `crearte-deploy/docker-compose.yml` 与 `.env.example` 中**均未定义**,README 也未提及 → `parseTrustedProxies("")` 返回空列表 → `SetTrustedProxies([]string{})`。
后果:`ClientIP()` 恒返回 nginx 容器 IP,**全站所有用户共用一个限流桶**。
spike 实测(`crearte-server/.superpowers/sdd-p11/spike_clientip_test.go.evidence`,gin v1.11.0):
| 输入 | `ClientIP()` 返回 |
|---|---|
| XFF=`203.0.113.7`,peer=`172.18.0.3` | `172.18.0.3` ← 真实用户 IP 被丢弃 |
| XFF=`198.51.100.42`,peer=`172.18.0.3` | `172.18.0.3` ← 第二个不同用户仍是同一值 |
限流器行为实测:两个不同用户各发一次,第三个请求即 `429`(`[204 204 429]`)——**第二个用户的首次尝试就被限流**。
> **⚠️ 端到端验收修正(2026-10-02,re-pin 前)**:上面两条 spike 把 peer 设为容器 IP(`172.18.0.3`)并直接带 XFF,**跳过了 compose 里横在 nginx 前面的 docker SNAT 层**。起真实栈后实测(见 `.superpowers/sdd-p11/FINDING-endpoint-snat.md` 与 `spike_snat_test.go.evidence`):docker 对发布端口做 SNAT,nginx 看到的源恒为网桥网关 `172.28.0.1` 而非真实客户端,故 nginx 追加进 XFF 的也是网关。**结论:在 compose 本机演练形态下,`ClientIP()` 无论如何都无法解析出真实客户端 IP**——缺陷 A 的「塌缩」是 SNAT 的固有后果,`TRUSTED_PROXIES` 修不了它;而按原 D-A 信任整个 `/24`(含网关)反而**引入限流绕过**(客户端预置 XFF 会被采纳)。原 D-A 已作废,见 §2 D-A′。
影响面按严重度排序(六桶的 limit/window 见 `internal/api/router.go`):
1. **`bundle-key`(60/min,`RouteBundleKey`)无鉴权且是游玩必需路径**:任意访客刷 60 次即让**全站所有用户无法加载任何 hosted 游戏**。这是当前最可被利用的一条——攻击者无需账号。
2. **`register`(5/hour)全站共享**:第 6 个访客(含爬虫、预取、健康检查)之后,**全站一小时无法注册**。
3. **`login`(10/min)全站共享**:任何 10 次登录尝试后全站锁死一分钟;同时这也让**按 IP 的口令爆破防护形同虚设**(攻击者与全体用户同桶,限流并不单独针对攻击者,而 `DummyVerify` 只挡了时序侧信道)。
4. `upload`(10/min)、`submission`(20/hour)、`reaction`(30/min):多用户同时投稿/评分时互相挤兑。
### 缺口 B:限流表无界增长(高危,且被 A 掩盖)
`maxTrackedIPs = 10000` **不是上限,只是过期清理的触发条件**。`allow()` 的清理循环只删除「窗口已过期」的表项;当大量不同 IP 在**同一窗口内**到达时,没有表项可删,map 持续增长。
spike 实测(`spike_xff_test.go.evidence`):`NewRateLimiter(60, time.Hour)`,15000 个不同 IP 各发一次 → `len(hits) == 15000`,超「上限」5000 条,**无一被逐出**。
耦合关系(本批的核心判断):**A 当前正在掩盖 B**——因为所有用户塌缩成一个 nginx 容器 IP,生产上 map 实际只有 1 条表项。修复 A 让 XFF 生效的同时,会把 B 从「结构上存在但不可达」变成「攻击者可达」:每个真实访客、每个僵尸网络节点、每个 IPv6 地址都成为一条新表项,而 `register`/`submission` 的窗口长达 1 小时。因此 **A 与 B 必须同批落地**,不得只做 A。
内存量级:`windowCounter`(`time.Time` 24B + `int` 8B = 32B)+ map 桶开销 + IP 字符串键(15–45B)≈ 每条 100–150B。10000 条 ≈ 1.5MB,可控;无界则随攻击流量线性增长。
### 缺口 C:`LOG_LEVEL` / `LOG_FORMAT` 大小写策略不一致(低,P8 deferred 债)
同一对取值在两层被以两种策略校验:
- `internal/config/config.go:88`(`applyLogging`)**严格**:`switch cfg.LogLevel { case "debug","info","warn","error": }`,`LOG_LEVEL=INFO` 直接返回错误、**进程启动失败**。
- `internal/observability/logging.go`(`parseLevel` / `newLogger`)**宽容**:`strings.ToLower(strings.TrimSpace(level))`,接受 `INFO`、` info `。
调用顺序是 `config.Load()`(严格)→ `observability.InitLogging(cfg.LogFormat, cfg.LogLevel)`(宽容),所以宽容分支永远收不到大小写不规范的输入,属死代码;而运维写 `LOG_LEVEL=INFO` 会得到一次启动失败。P8 第二批审查已裁决 deferred,本批收口。
## 2. 决策表
| # | 主题 | 定案 | 理由 |
|---|---|---|---|
| D-A′(re-pin,作废原 D-A) | 信任代理链怎么配 | **compose 默认 `TRUSTED_PROXIES` 为空**(不注入 subnet)。保留 `TRUSTED_PROXIES` 的 `.env` 可覆盖项与管线,但**默认值改为空**,并把「真实 prod 必须按实际反代 IP/网段配置」写进 README 作为部署前置。**subnet 固定同步回退**:初版(commit `1a7570b`)曾固定 `172.28.0.0/24`,但其唯一目的是给 `TRUSTED_PROXIES` 一个稳定值——空信任默认下与子网值无关,保留反而新增「与宿主其他项目网段冲突」的失败模式,docker 动态分配即可(实栈验证:栈起在动态 `172.19.0.0/16` 上全部断言成立,见 deploy CHANGELOG 0.7.0 Removed 段)。 | 端到端实测推翻原 D-A 的前提「subnet 是封闭边界、边界内只有本项目 web 容器」:**docker 网桥网关 `172.28.0.1` 也在该 subnet 内**,而 compose 发布端口时 docker 做 SNAT,使 nginx 看到的源恒为网关。信任整个 `/24` 于是把网关划进信任范围 → gin 右向左走信任跳时跳过网关、**采纳客户端预置的 XFF**(spike_snat 实测 `8.8.8.8, 172.28.0.1` + 信任 `/24` → `ClientIP()=8.8.8.8`)→ **限流可被客户端自选桶绕过**,比原缺陷更糟。而真实浏览器(不带 XFF)在 compose 下恒塌缩到网关(SNAT 固有),**缺陷 A 对合法流量无法靠 TRUSTED_PROXIES 修复**。故安全默认是空信任列表:XFF 被整体忽略、无绕过、`ClientIP()` 取 peer(nginx 容器 IP,仍是单桶但不引入新漏洞),D-C 告警如实提示。spike_snat 第 6 用例证明:真实 prod 形态(nginx 直接见真实客户端、信任 nginx)下 `ClientIP()` 能取到真实 IP 且拦截伪造——所以**空默认不损害真实 prod**,只是把配置责任交给运维(README 写明)。 |
| D-B | 限流表如何变成真上界 | `RateLimiter` 增加 `maxEntries` 字段(由 `NewRateLimiter` 设为 `maxTrackedIPs`)。`allow()` 在**新建表项前**:若 `len(hits) >= maxEntries`,先跑一次过期清理;仍满则**拒绝**该请求(返回 `false` → `429` + `Retry-After`),并且**不插入**表项 | 「满则拒」而非「满则逐最旧」:逐最旧会让攻击者用新 IP 冲刷把正常用户的计数器挤掉(等价于绕过限流),而满则拒是**失败关闭**——在极端情况下宁可多拒也不失去上界。既有键(已在表中的 IP)永远不受影响,只影响「表满时的新面孔」,而表满本身就是异常态。清理循环从 `> maxTrackedIPs` 改为 `>= maxEntries`,并把「清理」与「仍满则拒」写成同一条路径,避免只删不判的旧语义。 |
| D-C | 桶塌缩要不要加可观测性 | 在 `router.go` 装配处,当 `TrustedProxies` 为空且引擎已挂载限流器时,`slog.Warn` 一条启动告警,说明「所有客户端将共享同一限流桶」 | 这是「静默降级」类缺陷的通用解法:配置缺失时不猜测、不静默,而是**大声告诉运维**。空信任列表本身是合法配置(直连、无反代的部署),所以不能报错退出,但必须留痕。告警走既有 `slog`,不加新依赖、不加新配置项。 |
| D-D | `LOG_LEVEL`/`LOG_FORMAT` 哪边迁就哪边 | 在 `config.applyLogging` 中先 `strings.ToLower(strings.TrimSpace(v))` 归一,再按小写白名单校验;`observability` 层**保持原样** | 两层都宽容会让「配置值到底是什么」失去单一真相;两层都严格会让 `INFO` 这种常见写法启动失败。选择在**入口层归一**:`cfg.LogLevel` 从此恒为规范小写,下游(含 `observability.parseLevel` 的 ToLower)成为无害的幂等操作,无需改动、无需删它的宽容逻辑(它还被 `newLogger` 的单元测试直接使用)。 |
| D-E | nginx 模板要不要改 | 给两个 `location /api/` 块补 `proxy_set_header X-Real-IP $remote_addr;`,并在 server 块补 `add_header X-Content-Type-Options "nosniff" always;` 与 `add_header Referrer-Policy "strict-origin-when-cross-origin" always;` | `X-Real-IP` 给限流与日志一个**不经链式追加、不可被客户端预置污染**的单跳真相(nginx 用 `$remote_addr` 覆写,客户端发什么都会被替换),作为 XFF 的冗余校验与未来 `TrustedPlatform` 选项的入口。`nosniff` 与 `Referrer-Policy` 是静态资源与 API 反代共用的最低成本加固;`always` 保证 4xx/5xx 响应也带头。**不加 CSP**:本站的游玩子域要靠 iframe + Service Worker 加载用户上传的作品,CSP 需要单独一批设计与验证(挂账)。 |
| D-F | dev 侧要不要开 vite `xfwd` | **不做** | 已核实 vite 8.3.0 的 `ProxyOptions` 支持 `xfwd?: boolean`(`node_modules/vite/dist/node/index.d.ts:605`,bundled http-proxy 认它)。但 spike case 3/4 证明:dev 下浏览器经宿主端口进 web-dev,vite 看到的 peer 是 docker 网关(`172.28.0.1`),开 `xfwd` 只会把塌缩值从 vite 容器 IP 换成网关 IP——**不修复任何东西**,且 dev 本就是单用户 localhost 环境。改它属于无收益的前端改动,故 P11 不碰 `crearte` 的 `vite.config.ts`。 |
| D-G | 限流是否升级为共享存储(Redis 等) | **不做**,仅在 spec 记录权衡 | 多实例部署下进程内 map 仍是每实例独立(P3 已文档化该权衡)。引入 Redis 会带来新依赖、新故障域与新 compose 服务,而当前拓扑是单实例;「A 修好后按真实 IP 分桶」已经恢复了限流的设计意图。挂账为未来多实例化的前置条件。 |
| D-H | 是否顺手做 `td`→`th scope="row"` 之外的其他前端项 | **不做** | P11 是服务端批,前端只碰 `crearte/deploy/nginx.conf.template`(属部署资产,非应用代码)。保持批次边界清晰,避免与 P9-B 打磨批、C 暗色模式批冲突。 |
## 3. 实现
### 3.1 `internal/api/ratelimit.go`(D-B)
```go
type RateLimiter struct {
mu sync.Mutex
limit int
window time.Duration
maxEntries int
hits map[string]*windowCounter
}
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
return &RateLimiter{
limit: limit,
window: window,
maxEntries: maxTrackedIPs,
hits: map[string]*windowCounter{},
}
}
```
`allow()` 改为(保持既有签名与 `now` 注入以便测试):
```go
func (l *RateLimiter) allow(ip string, now time.Time) bool {
l.mu.Lock()
defer l.mu.Unlock()
counter, ok := l.hits[ip]
if !ok {
// 新面孔:先确认表有空间。maxEntries 是硬上界(D-B),
// 满则失败关闭——拒绝且不插入,避免无界增长。
if len(l.hits) >= l.maxEntries {
l.evictExpired(now)
if len(l.hits) >= l.maxEntries {
return false
}
}
l.hits[ip] = &windowCounter{start: now, count: 1}
return true
}
if now.Sub(counter.start) >= l.window {
counter.start = now
counter.count = 1
return true
}
if counter.count >= l.limit {
return false
}
counter.count++
return true
}
// evictExpired 删除窗口已过期的表项。调用方必须持有 l.mu。
func (l *RateLimiter) evictExpired(now time.Time) {
for key, counter := range l.hits {
if now.Sub(counter.start) >= l.window {
delete(l.hits, key)
}
}
}
```
要点:**既有键的路径语义与旧实现逐字等价**(窗口过期则重置为 `count:1` 并放行;未过期且已达 limit 则拒;否则自增放行),只有「新面孔」多了上界检查。旧实现里 `counter.start` 的重置发生在 map 赋值处,新实现改为原地改字段——效果相同,避免为已存在的键重新分配结构体。
### 3.2 `internal/config/config.go`(D-D)
```go
if v := os.Getenv("LOG_FORMAT"); v != "" {
cfg.LogFormat = strings.ToLower(strings.TrimSpace(v))
}
if v := os.Getenv("LOG_LEVEL"); v != "" {
cfg.LogLevel = strings.ToLower(strings.TrimSpace(v))
}
```
白名单 `switch` 与错误信息**保持不变**(校验的仍是小写集合;错误消息里回显的是归一后的值,便于运维看到实际被解析成什么)。`DefaultLogFormat`/`DefaultLogLevel` 已是小写,不动。
### 3.3 `internal/api/router.go`(D-C)
在 `SetTrustedProxies` 之后、路由注册之前插入:
```go
if len(proxies) == 0 {
// 无反代直连是合法拓扑,但此时 ClientIP() 恒为 peer IP。若 API 位于
// nginx/vite 之后而未配 TRUSTED_PROXIES,所有客户端会共享同一个限流桶
// (bundle-key 60/min、register 5/hour、login 10/min 均按 IP 计),
// 少量流量即可让全站拒绝服务。故大声留痕而非静默降级。
slog.Warn("api: no trusted proxies configured; client IP resolution falls back to the peer address, so every client behind a reverse proxy shares one rate-limit bucket",
"hint", "set TRUSTED_PROXIES to the reverse proxy's own IP/CIDR — never a range that also contains clients or the docker bridge gateway, or clients can spoof X-Forwarded-For to pick their own rate-limit bucket")
}
```
`internal/api` 需新增 `log/slog` import。
### 3.4 `crearte-deploy/docker-compose.yml`(D-A′)
**不新增顶层 `networks` 键**——subnet 固定已回退(理由见 §2 D-A′;初版 `1a7570b` 加了它,`08149aa` 删掉),docker 动态分配默认网络即可,空信任默认与子网值无关。
`x-api-env` 锚点(第 20–26 行)新增一行,但**默认值为空**(D-A′)——compose 本机演练下 docker SNAT 使真实客户端 IP 不可达,空信任列表是安全默认(XFF 整体忽略、无绕过、D-C 告警如实提示单桶):
```yaml
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-}
```
真实 prod 部署(nginx 直接见真实客户端,或前置公网 LB)由运维在 `.env` 里把 `TRUSTED_PROXIES` 设为实际反代 IP/网段(README 写明)。注意空默认下 `parseTrustedProxies("")` 返回空切片,`router.go` 的 `if proxies == nil` 分支不触发(空切片非 nil),但 `len(proxies) == 0` 仍真 → D-C 告警照常发。
兼容性:`api-dev`/`api-prod` 的 `networks: default: aliases: [api]`(服务级接入声明)不受影响——无顶层 `networks` 键时 compose 自动创建默认网络,服务名解析不变。**不得改动任何卷名**(`pgdata-*`/`minio-data-*`/`dev_node_modules`/`mock_node_modules` 是数据身份),不得改 `depends_on` 健康门控,不得移除 `${VAR:?…}` 守卫。
### 3.5 `crearte-deploy/.env.example`(D-A′)
在「prod 写侧可选项」段之后新增:
```
# --- 客户端 IP 解析与限流(默认留空,通常无需设置)---
# compose 本机演练下 docker 对发布端口做 SNAT:反代看到的源恒为网桥网关而非真实客户端,
# 真实客户端 IP 在本机形态下不可达,限流因而是单桶(演练环境单用户,可接受)。
# 切勿设为整个 compose 网段:网关也在网段内,信任它会让客户端预置的 X-Forwarded-For
# 被采纳,使限流可被自选桶绕过。详见 README「客户端 IP 解析与限流」。
# 真实 prod(反代直接见真实客户端,或前置公网 LB)才需设为实际反代 IP/网段:
# TRUSTED_PROXIES=
```
(`COMPOSE_SUBNET` 项随 subnet 固定回退一并移除。)
### 3.6 `crearte/deploy/nginx.conf.template`(D-E)
两个 `location /api/` 块各补一行:
```
proxy_set_header X-Real-IP $remote_addr;
```
两个 `server` 块各补两行(`listen`/`server_name` 之后):
```
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
```
注意 nginx `add_header` 的**继承陷阱**:子 `location` 内出现任何 `add_header` 会**完全屏蔽**父级 `server` 的 `add_header`。现有 `location = /index.html`、`/assets/`、`/data/`、`/data/bundles/` 都已有自己的 `add_header`,故这些路径**不会**继承新的两个安全头。定案:在 server 级加,并**同时**在已有的全部**七个**自带 `add_header` 的 `location` 块内各自补上同样两行(第一个 server:`= /index.html`、`/assets/`、`/data/bundles/`、`/data/`;第二个 server:`= /__bootstrap`、`= /sw.js`、`= /agent.js`),保证全站一致(这属于「加固必须无洞」而非过度设计)。合计落点:2 个 server 块 + 7 个 location 块 = **9 处**,每处两行 `add_header`;另 `X-Real-IP` 共 **2 处**(两个 `location /api/` 各一行)。
### 3.7 README(`crearte-deploy/README.md`,D-A′/D-E)
新增一节「客户端 IP 解析与限流」,说明:
- **compose 本机演练形态**:docker 对发布端口做 SNAT,nginx 看到的源恒为网桥网关 `172.28.0.1`,真实客户端 IP 不可达 → 默认 `TRUSTED_PROXIES` 空,限流是单桶(演练环境单用户,可接受),D-C 启动告警会如实提示。
- **为什么默认不信任整个子网**:网关也在子网内,信任它会让客户端预置的 XFF 被采纳(spike_snat 实测 `8.8.8.8` 被当成客户端 IP)→ 限流可被自选桶绕过。这是本批端到端验收抓出并 re-pin 的关键修正。
- **真实 prod 部署**:nginx 直接见真实客户端(host 网络/直接暴露)或前置公网 LB 时,把 `TRUSTED_PROXIES` 设为实际反代 IP/网段;此时 gin 右向左走信任跳能取到真实客户端 IP 且拦截伪造前缀(spike_snat 第 6 用例)。
- **如何验证**:查启动日志有无 D-C 告警(空信任列表时应有);真实 prod 配好后用两个不同真实来源打 `/api/auth/login` 观察是否各自独立计数。
- **`X-Real-IP` 与 `XFF` 的分工**:XFF 链式追加、可被客户端预置前缀污染(gin 从右向左走信任跳故仍安全);`X-Real-IP` 由 nginx 用 `$remote_addr` 覆写、单跳不可伪造。
compose 行为变更必须同步 README(AGENTS.md 硬性要求)。
## 4. 边界(明确不做)
- **不加 CSP**(iframe + Service Worker 加载用户作品,需独立设计批)。
- **不引入 Redis / 共享限流存储**(D-G,单实例拓扑下无收益,挂账为多实例化前置条件)。
- **不改 `vite.config.ts`**(D-F)。
- **不改 `observability/logging.go`**(D-D:归一在入口层做,下游宽容逻辑保留且变幂等)。
- **不改口令散列 / JWT / CORS**:已核实均为正确实现——pbkdf2-sha256 **600000** 轮 + `subtle.ConstantTimeCompare` + 不存在用户走 `DummyVerify` 挡时序侧信道;JWT `HS256` 钉死 + `WithValidMethods` + `WithExpirationRequired` + 32 字节密钥强校验;CORS 白名单精确匹配 + `Vary: Origin`。不是靶子,不动。
- **不改任何既有 handler 的 `Cache-Control`**:已核实 auth/reactions/account/admin_console 的敏感响应均已带 `no-store`。
- **不改六个限流器的 limit/window 数值**:本批修的是「按谁计数」与「表能否无界」,不是配额策略。
## 5. 测试计划
### T1(server)限流表硬上界
`internal/api/ratelimit_test.go` 扩展:
1. **RED→GREEN 上界**:`ratelimit_test.go` 是 `package api`(同包),可直接构造 `&RateLimiter{limit: …, window: …, maxEntries: 4, hits: map[string]*windowCounter{}}` 注入小上界——**不得为测试往生产代码加导出/私有构造函数**。填满表后:新 IP 被拒(`429`)、`len(hits)` **不超过** `maxEntries`、既有 IP 仍可正常计数(不被挤掉)。
2. **过期后可回收**:表满 → 时间推进超过窗口 → 新 IP 放行且 `len(hits)` 回落。
3. **既有语义不回退**:`TestRateLimiterAllowsUpToLimit`、`TestRateLimiterWindowResets` 逐字保留且继续绿(注意两者都用 `gin.New()` 且不设 `SetTrustedProxies`、只设 `RemoteAddr` 不设 XFF,gin 默认信任全网段故 `ClientIP()` 取 peer IP——新增上界逻辑不得改变这条路径)。
4. 把 spike 的 15000-IP 场景改写成**断言**(`len(hits) <= maxEntries`)而非日志——这是缺陷 B 的回归钉桩。该用例须用 `maxEntries` 注入的小值跑(否则真造 15000 条会拖慢套件);另保留一条用 `NewRateLimiter` 的断言,钉住生产构造确实用 `maxTrackedIPs` 作上界。
### T2(server)信任代理链解析
`internal/api/router_test.go` 扩展 + 新 `internal/api/clientip_test.go`:
1. `Deps.TrustedProxies` 为空 → `NewRouter` 返回的引擎对「peer 在容器网段 + XFF 带真实 IP」的请求,`ClientIP()` 返回 **peer IP**(XFF 整体忽略——这正是 D-A′ 的安全默认,也是 compose 本机演练的实际形态)。
2. `Deps.TrustedProxies = ["172.28.0.0/24"]` → 同样请求返回 **XFF 中的真实 IP**(真实 prod 形态:nginx 直接见真实客户端并追加,信任 nginx 后能取到真实 IP)。
3. **伪造抗性**(把 spike 五个场景变成断言):客户端预置 `X-Forwarded-For: 8.8.8.8, <真实IP>` / 多跳伪造 / 伪造信任网段内 IP / 双层代理——全部返回最右侧不可信跳,**不得**返回客户端可控的前缀值。
4. **启动告警**:`TrustedProxies` 为空时 `NewRouter` 产生一条 `slog.Warn`(用 `bytes.Buffer` + `slog.New(slog.NewJSONHandler(...))` 捕获,仿 `observability/logging_test.go:23` 既有范式),断言消息含 `trusted proxies`;非空时**不**产生该告警。告警断言只调 `NewRouter`、不发请求,避免 `RequestLogger` 噪声混入捕获 buffer。
5. all-trusted 边界钉桩(`spike_alltrusted_test.go.evidence`):XFF 全为信任跳时 gin 返回最左条目(dev 拓扑的网关 IP)——作为已知可接受行为钉桩(spec D-F),测试注释说明缘由。
6. **SNAT 网关陷阱钉桩(新增,`spike_snat_test.go.evidence`)**:`TrustedProxies = ["172.28.0.0/24"]`(含网关)+ peer=nginx 容器 IP + XFF=`8.8.8.8, 172.28.0.1`(客户端预置 + nginx 追加 SNAT 网关)→ `ClientIP()` 返回 **`8.8.8.8`**(客户端可控值被采纳)。这是**反面钉桩**:记录「信任整个含网关的 subnet 会引入限流绕过」这个陷阱,防止将来有人把 compose 默认改回 subnet 信任。测试注释须说明这正是 D-A′ 把默认改为空的原因。另钉:`TrustedProxies` 空 + 同 XFF → 返回 peer(nginx IP),伪造被忽略。
### T3(server)LOG 归一 + `parseTrustedProxies` 覆盖
`internal/config/config_test.go` 扩展 `TestApplyLogging`:
1. `LOG_LEVEL=INFO`、`LOG_FORMAT=" JSON "` → 成功,`cfg.LogLevel == "info"`、`cfg.LogFormat == "json"`。
2. `LOG_LEVEL=" warn "`(两侧空格)→ 成功归一为 `warn`。
3. `LOG_LEVEL=bogus` → 仍报错(既有断言保留)。
4. 空值 → 仍取默认(既有断言保留)。
5. 新增 `parseTrustedProxies` 测试(当前**零覆盖**;归 T3 因为它属 `internal/config` 包,与 T2 的 api 包分开以免并行冲突):`"172.28.0.0/24"` → 单元素切片;`"10.0.0.1, 192.168.0.0/16"` → 两元素(含 trim);`""` 与 `" , , "` → 空切片;`"not-an-ip"`、`"172.28.0.0/33"` → 报错。
### T4(deploy + crearte)编排与反代(D-A′ 修正后)
1. `docker compose --profile dev|prod|debug|mock config -q` 四个 profile 全过(AGENTS.md 要求)。
2. 断言 `config` 输出中 `api-prod`/`api-dev` 的 `TRUSTED_PROXIES` **默认为空**(未设 `.env` 时),且输出**无** subnet 固定(顶层 `networks` 键不存在,`config | grep subnet` 为空);另断言 `TRUSTED_PROXIES=10.0.0.0/8` 覆盖时跟随(管线仍通)。
3. **卷名不变**核查:`config` 输出的 `volumes` 键集合与改动前逐字相同(数据身份红线)。
4. `nginx -t` 校验模板渲染结果(`envsubst` + `docker run nginx:1.27-alpine nginx -t`)。
5. 起 prod 栈端到端(**D-A′ 的关键回归,用真实浏览器路径而非客户端自带 XFF**):`up -d --build` → `ps` 健康 → ① 启动日志**有** D-C 告警(空信任列表);② **不带任何 XFF** 打 `/api/auth/login`,api 日志 `ip=` 应为 nginx 容器 IP(单桶,SNAT 固有,如实记录);③ **客户端自带 `X-Forwarded-For: 8.8.8.8`** 打一次,api 日志 `ip=` 应仍为 nginx 容器 IP(**不是** `8.8.8.8`)——证明空信任列表下伪造 XFF 被忽略、无绕过。裸 `down`(绝不 `-v`)。
### T5(波次末,控制者)验收腿
- server:`gofmt -l`、`go vet`、`go test ./...`(docker 化 Go 1.24 + `GOPROXY=goproxy.cn`)、`-race` 腿、`TEST_DATABASE_URL` 指向 `db-test` 的集成腿(**绝不**指向 `db-debug`/`pgdata-dev`)。
- deploy:四 profile `config -q` + CI 的 `validate.yml` compose 腿。
- crearte:五腿全量不回退(vitest 626 / vue-tsc 0 / build OK / 主 e2e 80+1skip / noauth 4)——nginx 模板改动属部署资产,前端测试不应受影响,但必须实跑确认。
- 端到端:dev 栈 + prod 栈各起一次,`/healthz` 与 `/metrics` 冒烟,`down`(**绝不 `-v`**)。
## 6. CHANGELOG / 版本
- `crearte-server`:`0.17.0`(Fixed 段记 A/B/C,Added 段记 D-C 启动告警;Tests 段记测试增量)。
- `crearte-deploy`:`0.7.0`(Changed 段记 `TRUSTED_PROXIES` 空默认 + SNAT 绕过理由;**Removed 段记 subnet 固定回退**;文档段记 README 新节与 .env.example 说明块)。
- `crearte`:`0.24.1`(Changed 段记 nginx 模板的 `X-Real-IP` + 两个安全头;纯部署资产,patch 级)。
- wrapper `crearte-monorepo`:`0.3.4`(Done 段记 P11 交付 + ROADMAP P11 行 + 文档索引表补 spec/plan 两行)。
- 格式遵循各仓既有惯例:同条目英文行紧接中文行(无空行),不同条目间空行,高版本在上。
## 7. 证据存档
四份 spike 已存于 `crearte-server/.superpowers/sdd-p11/`(`.gitignore` 已加 `.superpowers/`,不入库):
- `spike_clientip_test.go.evidence` — 缺陷 A:塌缩实测 + 共享桶 `[204 204 429]`。
- `spike_xff_test.go.evidence` — 修复安全性(gin 右向左走信任跳,5 个伪造场景全过)+ 缺陷 B(15000 IP → `len(hits)=15000`)。
- `spike_alltrusted_test.go.evidence` — all-trusted 边界(XFF 全为信任跳时返回最左条目 = 网关 IP),D-F 判定依据。
- `spike_snat_test.go.evidence` — **compose SNAT 信任矩阵(D-A′ re-pin 的决定性证据)**:复刻 compose 拓扑(peer=nginx 容器 IP、XFF 含 SNAT 网关),六用例证明 ① 信任整个 `/24`(含网关)时客户端预置 `8.8.8.8` 被采纳(绕过);② 只信任 nginx IP 或信任空时伪造被拦截;③ 真实 prod 形态(nginx 见真实客户端、信任 nginx)下能取到真实 IP 且拦截伪造前缀。
- `FINDING-endpoint-snat.md` — 端到端验收发现全文:实测证据链(prod 栈 `ip=` 分布)、根因链(docker SNAT → nginx 追加网关 → 信任 subnet 含网关 → gin 跳过网关采纳客户端值)、原 D-A 设计错误如实记录、修正方向与 spike 验证。
gin v1.11.0 `ClientIP()` 源码已交叉验证:`trusted := c.engine.isTrustedProxy(remoteIP)`,仅当 peer 在信任列表内且 `ForwardedByClientIP` 时才走 `validateHeader`,否则 `return remoteIP.String()`。
@@ -0,0 +1,361 @@
# P12 窄屏溢出修复与打磨批 — 设计文档
- 日期:2026-10-02
- 涉及仓库:`crearte`(主)+ `crearte-server`(测试)+ `crearte-deploy`(CI)+ wrapper(记账)
- 前置:P11(`docs/specs/2026-10-02-p11-server-hardening-design.md`,已交付 server 0.17.0 / deploy 0.7.0 / crearte 0.24.1)
- 证据:`crearte/.superpowers/sdd-p12/FINDINGS-survey.md`(16 轮 throwaway spike 的实测输出固化,spike 文件已删、工作树净)
本批**零新功能、零后端行为变更**。做两件事:① 修四个实测可达的窄屏溢出缺陷;② 清偿 P9-B / P11 挂账的可动手打磨项,并把「本批缺陷类型」变成机器守卫(正面应用 P11 N6 的教训)。
---
## 1. 缺口(全部实测,非推断)
度量口径统一为 `document.documentElement.scrollWidth - clientWidth`(>0 即页面横向溢出),在真实构建产物上由 Playwright 实测(`build:e2e` + `serve-runtime.mjs --port 4173`)。
### 缺口 A(D1a):合法长用户名把页头撑出视口,且波及**全站每个路由**
`AppHeader.vue:102` 的 `<summary>` 直接插值 `{{ user.display_name }} ▾`,无任何宽度约束。60 字符 **ASCII** 名在 `word-break: normal` 下不可断行,summary 索取 484px 内容宽:
| 视口 | 实测 |
|---|---|
| 320px | `doc=700/320` → **+380px**;`sumW=484 sumRight=700` |
| 375px | `doc=700/375` → **+325px** |
| 768px | `/` ok;`/account` **+40px** — spike 17 反证更正:此 +40 由**本缺口(页头)**驱动,非缺口 B(回退 dd 修复 @768 仍 `over=+0`;回退页头 @768 → 红)。原表把它记在缺口 B 名下是归因错误 |
**可达性是硬事实,不是敌意构造**:后端 `internal/handler/auth.go:25` `maxDisplayNameLen = 60`、`:76` 错误文案「display name must be 1-60 characters」;前端 `app/auth/validation.ts:4` `DISPLAY_NAME_MAX = 60`、`RegisterView.vue:90` `maxlength="60"`。即**任何注册用户填满合法上限即触发**,且因页头全站常驻,`/`、`/games`、`/docs`、`/creator`、`/login`、`/account`、`/admin/*` 无一幸免。
24 字 **CJK** 名不触发(CJK 可断行,实测 `doc=320/320 ok`)——缺陷取决于**字符可断性**而非长度本身,故修复必须针对「不可断行串」而非「截断到 N 字」。
### 缺口 B(D1b):账号页昵称 `dd` 逃逸
`AccountView.vue:190` 的 `<dd class="text-sm font-bold">` 位于 `flex flex-wrap items-baseline gap-2` 内,但 60 字 ASCII 不可断行且 flex item 默认 `min-width:auto` 拒绝收缩:320px 实测 `ddW=542 ddRight=592 ddEscapes=true`(视口 320),`ddWhiteSpace=normal ddOverflowWrap=normal`。
**牙口位置(spike 17 2×2 消融实测)**:dd 固有宽 542px、左缘 50px → 右缘恒为 592px,故它在**任何 <592px 的视口**都溢出:回退本修复 @320px → `over=+272`(`scrollWidth=592`,即 `ddRight=592`)、@375px → `over=+217`。但 @768px dd 右缘 672 < 768,**本修复在 768 非必要**——该视口 `/account` 的 +40px 是缺口 A(页头)驱动(见上表更正)。即:dd 修复的牙在 320/375,页头修复的牙在全视口;两者独立必要、缺一不可,但**不是同一视口的同一症状**。
### 缺口 C(D2):目录页排序 select 的 `shrink-0`
`ResultMeta.vue:22` 的 `<div class="relative shrink-0">` 包裹排序 `<select>`,`shrink-0` 使其拒绝收缩。实测 `/games` @320px `doc=323/320` → **+3px**,**短名短数据也复现**(与缺口 A 无关);360px 及以上无溢出。spike 7 的四候选对照精确定位到它:仅「移除该包裹层 `shrink-0` + `min-width:0`」把 +3 归零,其余三个候选无效。
### 缺口 D(D3):五个 admin 表格零 overflow 包裹层,**短数据也溢出**
静态审计:`app/**` 共 **5 个 `<table>`**(`AdminUsersView.vue:115` 6 列、`AdminView.vue:150/173/220` 共 13 列、`AdminAuditView.vue:60` 5 列),`overflow-x`/`overflow-auto` 在表格上下文中**零命中**,全部 `table-layout: auto`、父级 `overflow-x: visible`。
短名短邮箱数据实测(与 display_name 长度无关):
| 路由 | 320px | 375px | 768px+ |
|---|---|---|---|
| `/admin/users` | **+76px**(`t0 w=364 right=396`) | **+21px** | ok |
| `/admin/audit` | **+38px**(`t0 w=326 right=358`) | ok | ok |
| `/admin` | ok(`t0 w=282`) | ok | ok |
根因:表格 `auto` 布局的 min-content 宽由各列固有内容决定(邮箱等宽串、`toLocaleString('zh-CN')` 日期、角色徽章、操作按钮),`w-full` 只是 `width:100%` 的**建议**,撑不破 min-content 下限;父级无裁剪 → 直接推宽文档。
### 缺口 E(D5):长名下用户下拉菜单逃逸视口
`AppHeader.vue:103` 的菜单是 `absolute right-0 w-32`,相对 `relative` 的 `<details>` 定位。缺口 A 使 details 本身宽 484px 且右边缘在 700px,菜单随之被推出屏外:60 字名 @320px 实测 `menu {left:357, right:485, escapesRight:true}`。**修好缺口 A 即自动修好本项**(spike 15/16 全部变体实测 `escapesRight:false`),无需独立改动,但须有断言钉住。
### 非缺口(勘查证伪,避免做无用功)
- **「移动端页头 / 汉堡菜单」挂账证伪**:nav 三链接内容宽仅 **74–158px**,320/360/375/412/768/1280px **全部零横向溢出**(spike 1+4)。ROADMAP 与账本中的该项挂账应从「待启动」改为「已证伪,删除」。
- **D4(nav 链接逐字换行)park**:320px 下「作品」渲染为 `作/品`(link `h=45`、`navH=45` vs 行高 `h-14`=56px),所有手机宽度都发生(含 375px = iPhone 12/13/14)。属**外观级**:无溢出、无截断(`selfOverflow=false`)、无功能损失。修法 `whitespace-nowrap` 实测在 320px 引入 **+11px** 溢出(demand 347 > 320),七种 gap 收缩组合(C1–C6)在「320px + 长名」下**全部失败**(+19~+75px)——logo(77) + nowrap nav(138–158) + 截断名(96) 物理放不下,与缺口 A 争同一份空间。需设计决策(更短的名上限 / 汉堡菜单 / nav 缩写),**不入本批**,转独立决策项。
---
## 2. 决策表
| # | 主题 | 定案 | 理由(实测依据) |
|---|---|---|---|
| D-A | `<summary>` 怎么截断 | **flex 三件**:`summary` 加 `flex max-w-[6rem] min-w-0 items-center gap-1`;名字包一层 `<span class="truncate min-w-0">`;`▾` 包一层 `<span class="shrink-0" aria-hidden="true">`;`summary` 加 `:title="user.display_name"` | spike 15 证伪了「运行时把名字节点包起来、▾ 留在外面」这条路:**Vue 把 `{{ user.display_name }} ▾` 编译成单个文本节点**,任何基于 `childNodes[0]` 的拆分都会把 `▾` 一起搬进截断 span(V2–V4 实测 `caret=HIDDEN`)。必须改模板显式拆两个 span。spike 16 六变体实测:F1(flex 无 cap)在 60 字名时 320px **+390**、375px 同样 ❌(纯 shrink 无效,summary 仍索取内容宽;375px 的具体 px 当时未归档,终审 N-F2 故删去原写的「+405」——该数无出处,且不承载任何结论:归档的「F1 仍 ❌」已足够);F2(7rem)320px **+7**;**F3(6rem)320/375px × {短名, 60字ASCII, 60字CJK} 六格全 `over=+0` 且 `caret=VIS`**;F4/F5(5/4rem)也过但 summary 更窄、无额外收益。6rem 同时是 spike 15 整体截断形态的实测赢家(`sumRight=311 ≤ 320`),两种形态上限一致 → 定 6rem。`title` 承载全名(鼠标悬停可读全文),避免截断丢信息。 |
| D-B | 包裹层为什么必须 `relative` | 五个表格的包裹层统一为 `class="relative overflow-x-auto pr-1 pb-1"` | **`relative` 不是装饰,是修 bug**:P9-B 为空「操作」列头补的 `<span class="sr-only">操作</span>` 是 `position:absolute` + `margin:-1px`,而 Tailwind 的 `sr-only` **不含 `top`/`left`** → 其包含块是最近的**已定位**祖先。`position:static` 的 `overflow-x:auto` 包裹层不构成包含块,该 span 逃出滚动裁剪,**独自**把文档撑宽:烧蚀实验(逐元素 `display:none` + 重测权威度量)定位到 `d=11 span.sr-only l=351 r=352 w=1`,短数据残差 `doc=352` = 其右边缘 352;长数据 `doc=1061` = 其右边缘 1061。加 `relative` 后残差归零(spike 12 W1 实测 `over=+0`;W2「th 加 relative」同样 `over=+0`,选 W1 因改动集中在一处新增元素而非逐个 `<th>`)。这也解释了 spike 9 的 `trueOffenders=0` 矛盾:offender 扫描把「有可滚动祖先」的元素当合法溢出筛掉了,而它恰恰逃出了容器——**边框盒扫描看不见它,只有烧蚀能**。 |
| D-C | 包裹层要不要留 padding | 留 `pr-1 pb-1`(4px) | 五个表格都带 `shadow-hard` = `4px 4px 0 #141414`(`main.css:20`)。overflow 裁剪发生在 **padding box**,无 padding 时 `roomBottom=0`(阴影被裁),`pr-1 pb-1` 时 `roomBottom=4` 恰好容下(spike 7 Q2 / spike 12 W3 对照实测)。Tailwind v4 `p*-1` = 0.25rem = 4px,与阴影偏移逐值相等。 |
| D-D | `v-else` 怎么处置 | **`v-else` 从 `<table>` 上移到包裹层** | 三个表格是 `<p v-if="空态">` + `<table v-else>` 的**相邻兄弟配对**(`AdminUsersView.vue:114-115`、`AdminView.vue:149-150`、`AdminAuditView.vue:59-60`)。把 `<table>` 包进 `<div>` 而不迁移 `v-else`,会使 `v-else` 失去配对对象 → Vue 编译报错或空态/表格逻辑错乱。另两个表格(`AdminView.vue:173/220`)无 `v-if` 兄弟,仅包裹、不涉 `v-else`。 |
| D-E | 排序 select 怎么改 | `ResultMeta.vue:22` 的 `relative shrink-0` → `relative min-w-0` | spike 7 四候选对照中唯一把 `/games` @320px 的 +3px 归零的就是它(候选 B);同排的 `shrink-0` 计数 `<p>`(`:20`)与「筛选」按钮实测**不是**驱动元素(候选 C/D 无效)。保留 `relative`(`PhCaretDown` 靠它绝对定位)。 |
| D-F | `dd` 怎么改 | `AccountView.vue:190` 的 `text-sm font-bold` → `text-sm font-bold min-w-0 wrap-anywhere` | `min-w-0` 解除 flex item 的 `min-width:auto` 下限(否则拒绝收缩),`wrap-anywhere`(`overflow-wrap:anywhere`)让不可断行串可在任意位置折行。已查包确认 Tailwind 4.3.3 **存在** `wrap-anywhere` 工具类(`node_modules/tailwindcss/dist/lib.js` 命中),非推断。**不用** `break-all`:它对 CJK/拉丁混排断词更粗暴,且本处只需「允许在任意点折行」而非「强制逐字断」。 |
| D-G | 可访问名怎么保住 | 不改任何 `aria-*`、`scope`、`sr-only` 结构;`▾` 的 span 加 `aria-hidden="true"` | `▾` 是纯装饰字形(既有代码里它就是文本节点的一部分,从未参与语义)。加 `aria-hidden` 后 `<summary>` 的可访问名从「用户名 ▾」变为「用户名」——**语义更准**(`▾` 不是名字的一部分),且 `e2e/ux.spec.ts:26` 的 locator `header details summary` 与断言(`toBeVisible` / 点击 / `details[open]` 计数)不依赖可访问名文本,实测不破。`e2e/ux.spec.ts:25` 的注释「details summary 的可访问名即用户名」在改动后**反而更准确**(原先含 `▾`),同批更新该注释措辞。 |
| D-H | 机器守卫怎么做 | 两层:① **e2e 视口守卫** `e2e/responsive.spec.ts`(进 CI 主腿);② **源码级守卫** `app/lib/tableOverflow.test.ts`(沿用 `tableScope.test.ts` 范式) | P11 终审 N6 的核心观察:**文档层防御密集但无机器守卫**,改回坏配置不会被任何测试抓住。本批把这个教训正面应用——D1/D2/D3 全部是「人眼看构建产物才会发现」的缺陷,正是 e2e 视口断言的用武之地。`playwright.config.ts` 的 `testIgnore: /noauth\.spec\.ts/` 意味着新增 `responsive.spec.ts` **自动进 CI `e2e` job**(`validate.yml:50` 跑 `npm run e2e`),无需改 CI 配置。源码级守卫按「每个 `<table>` 的**父元素**是否带 `overflow-x-auto`」判定,**不能**用全局 `overflow-x-auto` 计数 1:1 断言——`DocSidebar.vue:25` 已有一处用在 `<nav>` 上(实测基线 = 1 而非 0),全局计数会写错。 |
| D-I | 打磨项取舍 | 纳入 P9B-1/P9B-2/P9B-4、P11-N5/N6;**不纳入** P9B-3(已修)、P11 其余次要 notes(纯留档,无可动手改动) | 逐条判据见 §3.5。关键:P9B-2 与 P9B-4 都改既有断言覆盖的行为,须先证明**既有断言不破**再动手——P9B-2 的 `a11y.spec.ts:61` 在未评分态(`rated=false`)下断言 `评 3 星`,动态标签只在「已评且 i === score」时改写,故该断言路径不变;P9B-4 的六处 `toast-announce` 断言均不涉及「手动关掉最新一条」,新语义是**追加**而非改写。 |
---
## 3. 实现
### 3.1 `crearte/src/app/components/AppHeader.vue`(D-A / D-G / 缺口 E)
当前 `:101-102`:
```vue
<details v-else ref="detailsRef" class="relative ml-auto sm:ml-0" @toggle="menuOpen = ($event.target as HTMLDetailsElement).open">
<summary ref="summaryRef" class="list-none cursor-pointer select-none border-2 border-ink bg-surface px-2 py-1 text-xs font-bold [&::-webkit-details-marker]:hidden">{{ user.display_name }} ▾</summary>
```
改为(`<details>` 一行**不动**,仅改 `<summary>` 及其内容):
```vue
<summary
ref="summaryRef"
:title="user.display_name"
class="flex max-w-[6rem] min-w-0 list-none cursor-pointer select-none items-center gap-1 border-2 border-ink bg-surface px-2 py-1 text-xs font-bold [&::-webkit-details-marker]:hidden"
>
<span class="min-w-0 truncate">{{ user.display_name }}</span>
<span class="shrink-0" aria-hidden="true">▾</span>
</summary>
```
`ref="summaryRef"` 必须保留(`onDocKeydown` 的 Esc 回焦依赖它,见 `:26` `summaryRef.value?.focus()`)。`truncate` = `overflow:hidden` + `text-overflow:ellipsis` + `white-space:nowrap`,配合 `min-w-0` 才能在 flex 子项上生效。
### 3.2 `crearte/src/app/views/{AdminUsersView,AdminView,AdminAuditView}.vue`(D-B / D-C / D-D)
五处统一模式。**带 `v-else` 的三处**(`AdminUsersView.vue:115`、`AdminView.vue:150`、`AdminAuditView.vue:60`)——`v-else` 上移:
```vue
<!-- 改前 -->
<table v-else class="w-full border-2 border-ink bg-surface text-sm shadow-hard">
…
</table>
<!-- 改后 -->
<div v-else class="relative overflow-x-auto pr-1 pb-1">
<table class="w-full border-2 border-ink bg-surface text-sm shadow-hard">
…
</table>
</div>
```
**不带 `v-else` 的两处**(`AdminView.vue:173`、`AdminView.vue:220`)——仅包裹:
```vue
<div class="relative overflow-x-auto pr-1 pb-1">
<table class="w-full border-2 border-ink bg-surface text-sm shadow-hard">
…
</table>
</div>
```
表格开闭行号(供核对,改动会移动后续行号,以内容锚点为准):`AdminUsersView.vue` 115→161;`AdminView.vue` 150→165、173→214、220→239;`AdminAuditView.vue` 60→82。表格**内部**(`<thead>`/`<tbody>`/`<th scope>`/`data-testid`)一字不动——P9-B 的 `scope="col"` 与 sr-only「操作」必须原样保留(`a11y.spec.ts:52` 断言 `headers.nth(5)` 可访问名为 `操作`)。
### 3.3 `crearte/src/app/components/ResultMeta.vue`(D-E)
`:22` 单行改动(全文件仅此一处 `relative shrink-0`,唯一匹配):
```vue
<!-- 改前 --> <div class="relative shrink-0">
<!-- 改后 --> <div class="relative min-w-0">
```
该文件共 **4 处** `shrink-0`(元素级核准),只改 `:22` 这一处,**其余三处不动**:`:18` 计数 `<p class="shrink-0 text-[0.9375rem] font-extrabold" aria-live="polite">`、`:20` `<PhArrowsDownUp … class="hidden shrink-0 text-ink-soft sm:block" />`、`:43`「筛选」按钮 `class="lift flex shrink-0 items-center gap-1.5 …"`。spike 7 的四候选对照实测:只有移除 select 包裹层的 `shrink-0`(候选 B)能把 `/games` @320px 的 +3px 归零;针对计数 `<p>`(候选 C)与筛选按钮(候选 D)的改动均**无效**(over 仍 +3px),故不动它们。
### 3.4 `crearte/src/app/views/AccountView.vue`(D-F)
`:190` 单行改动:
```vue
<!-- 改前 --> <dd class="text-sm font-bold">{{ user.display_name }}</dd>
<!-- 改后 --> <dd class="min-w-0 text-sm font-bold wrap-anywhere">{{ user.display_name }}</dd>
```
同文件 `:194` 的用户名 `dd` 与其他 `dd` **不动**(用户名有 `maxlength="39"`,实测不溢出)。
### 3.5 打磨项(跨仓,各自独立)
**(a) P9B-1 — `crearte/src/app/router/index.test.ts:111`**
当前弱断言:
```ts
await expect(router.push('/docs')).resolves.not.toThrow()
```
它依赖 vue-router 5.3.1「`afterEach` 抛错不被吞」的语义,升级后若改为吞抛则**恒真**(假绿)。改为直接单测被测函数本身,绕开 router 版本语义:
```ts
// 直接验证 focusMain 在 #main 缺失时静默 no-op(不经 router,故不依赖
// vue-router「afterEach 抛错不被吞」的版本语义——那会让断言退化为恒真)
expect(() => focusMain()).not.toThrow()
```
并从 `@/router` 补 `focusMain` 到既有 import。保留其后的 `expect(document.title).toBe('文档 · crearte 创艺')`(标题职责仍经 router 验证)。
**(b) P9B-2 — `crearte/src/app/components/GameReactions.vue:105`**
当前每颗星静态 `:aria-label="`评 ${i} 星`"`,读屏用户听「评 4 星」无法预知「再点同一颗星会撤评」(该控件支持点当前分取消)。改为按状态动态:
```vue
:aria-label="rated && i === score ? `已评 ${i} 星,点击取消评分` : `评 ${i} 星`"
```
`rated`/`score` 均为既有 ref(`:16-17`,由 `apply(view)` 从服务端 `ReactionView` 全量替换,`:44-51`)。**既有断言不破**:`e2e/a11y.spec.ts:61` 在 `seedSession(page,'user')` + 夹具无个人评分下走 `rated=false` 分支,可访问名仍是 `评 3 星`。新增单测须覆盖两态(未评 → `评 N 星`;已评 N → `已评 N 星,点击取消评分`;已评 M≠N → `评 N 星`)。**不改** `role="group"` 的 `:aria-label`(`:98`,P9-B 已定稿)与星形字形 `aria-hidden`。
**(c) P9B-4 — `crearte/src/app/components/ToastHost.vue:22-28`**
当前 watch 源是**数组末尾条目的 id**:
```ts
watch(
() => toasts.value[toasts.value.length - 1]?.id,
(id) => {
announcement.value =
id === undefined ? '' : (toasts.value[toasts.value.length - 1]?.text ?? '')
}
)
```
饱和态(`MAX_VISIBLE=3`)下 push 会「逐最旧 + append」,长度 3→3 不变而末尾 id 变,故 P9-B 特意以 id 为源(这个选择是对的,**保留**)。副作用是:**手动关掉最新一条**时末尾 id 回退到较旧条 → watch 触发 → 播报区**重念一条仍在屏上的旧消息**(自动过期不触发,因为过期的是最旧条)。修法:只在 id **单调变新**时播报,并记住已播报的最大 id:
```ts
const announcement = ref('')
// 已播报的最大 id:useToast 的 nextId 单调递增,故「比它大」即「新消息」。
// 手动关掉最新一条会让数组末尾 id 回退到较旧条,若不比较大小就会重念一条
// 仍在屏上的旧消息(P9B-打磨4)。
let announcedId = 0
watch(
() => toasts.value[toasts.value.length - 1]?.id,
(id) => {
if (id === undefined) {
// 全部消失:清空播报,但不重置 announcedId(后续新消息 id 必然更大)
announcement.value = ''
return
}
if (id <= announcedId) return
announcedId = id
announcement.value = toasts.value[toasts.value.length - 1]?.text ?? ''
}
)
```
**六处既有断言均不破**(逐一核对 `ToastHost.test.ts`):`:49-53` 常驻空播报(初始 `announcement=''`);`:61` push 后等于该消息(id 1 > 0);`:74-78` 消失后清空(`id===undefined` 分支);`:98` 饱和态播报第 4 条(id 4 > 3);`:106` 播报节点内零交互控件(结构未变)。新增单测须覆盖「三条并存 → 关掉最新 → 播报文字**不变**(不重念旧消息)」。注意 `announcedId` 是模块内闭包变量,而 `toasts` 是 `useToast` 的模块级单例 ref——测试间须用既有 `__resetToasts()`(`useToast.ts:38`)隔离;若 `announcedId` 残留导致跨测试污染,改为把它也挂到 store 侧或在 `__resetToasts` 里一并复位(实现者按实测选择,并在测试注释里写明)。
**(d) P11-N5 — `crearte-server/src/internal/config/config_test.go`**
P11 T3 加了归一化,但「错误消息回显归一化后的值」无钉桩。在 `TestApplyLogging` 的 bogus 段(`:264` 附近,`LOG_FORMAT` bogus 之后)补:
```go
// 归一化后的错误消息须回显归一化值(P11-N5):运维看到 " BOGUS " 原样
// 会以为是空格问题,回显 "bogus" 才指向真正的白名单不匹配。
t.Setenv("LOG_LEVEL", " BOGUS ")
err := applyLogging(&cfg)
if err == nil {
t.Fatal("invalid LOG_LEVEL must fail after normalization")
}
if !strings.Contains(err.Error(), "bogus") {
t.Errorf("error = %q, want it to echo the normalized value %q", err, "bogus")
}
if strings.Contains(err.Error(), " BOGUS ") {
t.Errorf("error = %q, must not echo the raw un-normalized value", err)
}
```
须先 `grep -n "\"strings\"" internal/config/config_test.go` 确认 import;缺失则补。先读 `applyLogging` 现有错误文案,确认它确实回显归一化值(P11 T3 报告称是)——若实测不回显,则本项从「补测试」变为「补实现 + 测试」,须在报告里说明。
> **勘误(2026-10-02,终审 N-F1)**:上方片段的第一条断言写的是裸子串 `strings.Contains(err.Error(), "bogus")`。审查 note SD-N1 指出它有理论盲区:若实现改为回显**小写但未 trim** 的 `" bogus "`,裸子串断言与下方大小写敏感的原样形式检查**会同时放过**。落地的 `config_test.go`(server `fa39d27`)已收紧为 `%q` 渲染出的**带引号形式** `` `"bogus"` ``,一次钉住 trim 与 lower 两个性质。终审已用 mutation 独立证实两条断言对「回显原始 env」同时开火。**不要按上方片段把它弱化回裸子串。**
**(e) P11-N6 — `crearte-deploy/.github/workflows/validate.yml`**
在 `compose` job 的「compose parses under every profile」step 之后新增一个 step,把 P11 D-A′ 的**空默认**变成机器守卫(终审 N6:现无任何自动化测试能抓住 compose 文件被改回 subnet 信任):
```yaml
- name: TRUSTED_PROXIES defaults empty (P11 D-A' guard)
env:
POSTGRES_PASSWORD: "***"
MINIO_ROOT_USER: ci
MINIO_ROOT_PASSWORD: "***"
AUTH_TOKEN_SECRET: "***"
BUNDLE_KEK_k1: ci-not-a-real-secret
run: |
set -euo pipefail
# 空默认是安全默认:信任整个 compose 网段会把 docker 网桥网关划进信任范围,
# 使客户端预置的 X-Forwarded-For 被采纳(限流可自选桶绕过)。
# 详见 README「客户端 IP 解析与限流」。
rendered="$(docker compose --profile prod config)"
# 未设 .env 时必须解析为空字符串(config 输出形如 TRUSTED_PROXIES: "")
echo "$rendered" | grep -q 'TRUSTED_PROXIES: ""' \
|| { echo 'TRUSTED_PROXIES must default to empty — see README' >&2; exit 1; }
# 且不得出现网段信任(172.28.0.0/24 或任何 COMPOSE_SUBNET 派生)
if echo "$rendered" | grep -Eq 'TRUSTED_PROXIES: "[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+'; then
echo 'TRUSTED_PROXIES must not default to a CIDR (docker SNAT makes the bridge gateway trusted)' >&2
exit 1
fi
# 覆盖仍须生效(管线未断)
TRUSTED_PROXIES=10.0.0.0/8 docker compose --profile prod config \
| grep -q 'TRUSTED_PROXIES: "10.0.0.0/8"' \
|| { echo 'TRUSTED_PROXIES override pipeline broken' >&2; exit 1; }
```
> **勘误(2026-10-02,审查 SD-D1 定案)**:上方字面片段的断言②③模式写的是**带引号**形式(`TRUSTED_PROXIES: "[0-9]+…`、`TRUSTED_PROXIES: "10.0.0.0/8"`),但 compose 实测只对**空串**加引号,非空标量**裸渲染**(`TRUSTED_PROXIES: 10.0.0.0/8`)。照抄字面片段会同时产出:②**恒不匹配的死断言**(改回 CIDR 默认也放行 = 假信心)与③**恒红的假警**(正向 CI 必炸)。落地的 `validate.yml`(deploy `17748bf`)已改为兼容裸标量的 ` *[0-9]` / ` *10\.0\.0\.0/8` 并渲染到文件复用,三态实跑验证(正向绿 / 注入 CIDR 红 / 删注入行红)。**不要按字面片段「修回去」。**
注意 `paths:` 过滤器已含 `docker-compose.yml` 与 `.github/workflows/validate.yml`,无需改触发条件。**本地无法跑 GitHub Actions**,故本项的验证方式是:把 `run:` 块内容作为脚本在本地对 `docker compose --profile prod config` 实跑一遍(含覆盖分支与「故意改坏 → 应失败」的反向验证),并在报告里贴输出。反向验证必须做——只证明「当前通过」不证明守卫有牙(P11 mutation 抽查同理)。
---
## 4. 边界(不做的事)
- **不动 D4**(nav 逐字换行):外观级、无溢出、修法在 320px 与缺口 A 争空间,需独立设计决策。
- **不引入汉堡菜单 / 不改 nav 结构**:勘查已证伪其必要性。
- **不动 CSP / HSTS / TLS**:P11 已明确挂账为独立批(CSP 需设计——游玩子域经 iframe+SW 加载用户作品;HSTS 待 TLS)。
- **不动后端任何行为**:`maxDisplayNameLen=60` 是既有合法契约,本批不改校验规则(改短会破坏已有账号数据)。缺口 A 是**前端渲染**未防御合法输入,修在前端。
- **不改 `sr-only` 的 Tailwind 定义或 P9-B 的 `scope="col"` 结构**:D-B 的 `relative` 是在**包裹层**上补包含块,不动 sr-only 本身(它是全仓通用工具类,改它波及面不可控)。
- **不加 `overflow-x-hidden` 到 body/html**:那会把溢出**藏起来**而非修掉,且会裁掉 `sticky` 页头与 `shadow-hard`。缺口必须真消除(实测 `scrollWidth == clientWidth`),不是视觉遮盖。
- **不动 `AdminView.vue` 的三表内部结构**(列数、`data-testid`、按钮文案):只加包裹层。
---
## 5. 测试计划
### T1(AppHeader,缺口 A/E)
1. 既有 `app/components/AppHeader.test.ts` 六个测试全绿(它们用 `w.get('summary')` / `w.get('details')` / `w.get('details a')`,与 summary **内部**结构无关;`:23` 的 mock `display_name: 'tester'`)。
2. 新增单测:60 字 ASCII 名下 ① `<summary>` 有 `title` 属性且值为全名;② 名字 span 带 `truncate`、`▾` span 带 `aria-hidden="true"`;③ summary 的可访问名不含 `▾`。
3. e2e:`e2e/responsive.spec.ts`(见 T4)覆盖 320/375px × 60 字名 × 6 路由零溢出 + 菜单不逃逸。
### T2(三视图五表格,缺口 D)
1. 既有 `e2e/a11y.spec.ts:50/52` 的 `columnheader` 断言全绿(`headers` 计数仍 6、`nth(5)` 可访问名仍 `操作`)——`getByRole('columnheader')` 不受包裹层影响,须实跑确认。
2. 既有 `e2e/admin-flow.spec.ts` 全绿(`queue-sub-p1` 等 `data-testid` 定位不变)。
3. 新增源码级守卫 `app/lib/tableOverflow.test.ts`:扫 `app/**/*.vue`,对每个 `<table` 起始标签,向上取**同一文件文本内**最近的开标签父元素,断言其 `class` 含 `overflow-x-auto`;违规报 `文件:行号: 行内容`。沿用 `tableScope.test.ts` 的 `walk()`/`candidates()`/跨行起始标签截取范式。**不得**用全局 `overflow-x-auto` 计数断言(`DocSidebar.vue:25` 的 `<nav>` 是合法既存用例,基线 = 1)。
4. e2e:短数据下 `/admin/users`、`/admin`、`/admin/audit` 在 320/375px 零溢出,且表格**仍可横向滚动到**(断言包裹层 `scrollWidth > clientWidth` 时内容可达——修掉溢出不能靠藏内容)。
### T3(ResultMeta + AccountView,缺口 B/C)
1. 既有 `e2e/author-page.noauth.spec.ts:12` 的 `${count} 款作品` 断言全绿(只改类名,不动文本)。
2. 既有 `app/views/CatalogView.test.ts` 全绿。
3. e2e:`/games` @320px 零溢出;`/account` 在 60 字名下 320/375/768px 零溢出。(**归因更正**:spike 17 2×2 消融实测,768px 的 +40px 由缺口 A 页头驱动、非本项 dd——回退 dd @768 仍 `over=+0`;dd 修复的牙在 320/375px,回退 dd @320 → `scrollWidth=592`。故 768 腿钉的是**页头回归**,320/375 腿才是本项 dd 的牙口。)
### T4(机器守卫,D-H)
1. 新增 `e2e/responsive.spec.ts`,**必须进 CI 主腿**(`playwright.config.ts` `testIgnore: /noauth\.spec\.ts/` 不含它):
- 视口 320 / 375;名字形态 短名(`安`)/ 60 字 ASCII / 60 字 CJK;登录态 登出 / user / admin。
- 路由 `/`、`/games`、`/docs`、`/creator`、`/login`、`/register`、`/account`、`/admin/users`、`/admin`、`/admin/audit`(后四个需 admin seed + 最小 mock,沿用 `a11y.spec.ts:26` 与 `admin-flow.spec.ts:30-56` 的端点范式;表格由 `v-else` 门控,**mock 必须返回至少一条数据**否则 `<thead>` 不渲染、守卫空跑)。
- 每格断言 `document.documentElement.scrollWidth <= clientWidth + 1`。
- 另断言:60 字名下 `header details > div` 菜单 `right <= innerWidth`(缺口 E 钉桩);`<summary>` 的 `▾` 仍**可见**(防止将来有人用「整体 truncate」把 caret 吞掉——spike 15 实测那条路 `caret=HIDDEN`)。
- `seedSession` 的 `display_name` 硬编码为 `role`(`helpers.ts:37`),长名场景需**扩展可选参数**(`seedSession(page, role, displayName?)`,默认值保持 `role` 以免动既有 21 个 spec)或在 `responsive.spec.ts` 内自带 seed——二选一,实现者定,但**不得**改既有调用点的行为。
- 组合数控制:不必跑满 3×3×10 全矩阵(会拖慢 CI)。至少覆盖 {320,375} × {60字ASCII, 短名} × {全部 10 路由} + {375} × {60字CJK} × {`/`, `/account`, `/admin/users`}。总时长目标 ≤ 90s(参考:既有主 e2e 81 测试 1.1min)。
2. 守卫**必须有牙**:实现者须在本地临时 revert 任一修复(如把 `relative` 去掉、或把 `max-w-[6rem]` 去掉),确认 `responsive.spec.ts` / `tableOverflow.test.ts` 变红,再恢复;报告里贴红/绿两次输出。这是 P11 mutation 抽查纪律的落地。
### T5(打磨项)
1. (a) `router/index.test.ts` 全绿,且新断言不经 router(版本无关)。
2. (b) `GameReactions.test.ts` 新增两态可访问名断言;既有 `e2e/a11y.spec.ts:61` 不破(实跑确认)。
3. (c) `ToastHost.test.ts` 既有六处断言全绿 + 新增「关掉最新条不重念旧消息」;`__resetToasts()` 隔离实测有效。
4. (d) server `go test ./internal/config/ -run TestApplyLogging -v` 绿;`gofmt -l .` 空、`go vet ./...` 0。
5. (e) deploy 守卫脚本本地实跑三态:当前通过 / `TRUSTED_PROXIES=172.28.0.0/24` 注入 `.env` 时**失败** / 覆盖 `10.0.0.0/8` 时通过。四 profile `config -q` 仍全绿。
### 全量回归(合并前必跑)
- crearte:`npm run test`(vitest,基线 **626** + 本批新增)、`npm run typecheck`、`npm run build`、`npm run build:runtime`、`npm run e2e`(基线 **80+1skip** + 新增 responsive)、`npm run e2e:noauth`(基线 **4**)。**脚本名先查 `package.json`**——P11 曾把 `test`/`typecheck` 写成 `test:unit`/`type-check` 配 `--if-present` 导致假绿。
- server:容器化 `gofmt -l .` / `go vet ./...` / `go build ./...` / `go test ./... -count=1`(基线 11 包 ok)。
- deploy:四 profile `config -q` + 卷名集合与基线逐字相同。
- 管道后取退出码一律 `set -o pipefail` 或不管道(P11 三度踩坑)。
---
## 6. CHANGELOG 与版本
- `crearte`:**0.25.0**(Fixed 段记缺口 A/B/C/D/E 四缺陷 + 下拉菜单逃逸;Added 段记两层机器守卫;Changed 段记打磨三项 P9B-1/2/4)。
- `crearte-server`:**0.17.1**(Tests 段记 P11-N5 错误消息回显钉桩;纯测试,patch 级)。
- `crearte-deploy`:**0.7.1**(CI 段记 P11-N6 `TRUSTED_PROXIES` 空默认机器守卫;纯 workflow,patch 级)。
- wrapper:**0.3.5**(Done 段记 P12 交付;ROADMAP P12 行 + 文档索引表补 spec/plan 两行;同时把「移动端页头/汉堡菜单」挂账标注为**已证伪删除**、D4 标注为 **park 待设计决策**)。
条目格式沿用 AGENTS.md:同条目英文行紧跟中文行(无空行),不同条目间空行。
---
## 7. 证据存档
`crearte/.superpowers/sdd-p12/`(`.gitignore` 已含 `.superpowers/`,不入库):
- `FINDINGS-survey.md` — **16 轮 spike 的实测输出固化**:四轮基线审计与「移动端页头」证伪、D1a 发现与范围扩张(三渲染点)、D3 独立性证明(短数据)、修法候选对照(含两个实验缺陷:Q1 误抓 sr-only `<h2>`、Q2 被 D1 污染)、残差归因三轮(矛盾 → 逐层探测 → **烧蚀定案 sr-only span**)、H1/H2 机制验证 + H3 24 格全绿矩阵、D4 七候选全灭 → park、D1a 精确形态定案(Vue 单文本节点导致 V2–V4 失效 → flex F3 胜出)。每个数字都有出处。
- 本 spec §1/§2 的所有实测值均引自该文件;spike 源文件跑完即删(工作树 `porcelain=0` 已核),不入库。
@@ -0,0 +1,530 @@
# P13 设计:暗色模式(Dark Mode)
- **状态**:已定稿,待实现
- **日期**:2026-10-03
- **批次**:P13(UX 第三批 / 挂账清偿)
- **范围**:仅 `crearte`(前端)。**零后端改动、零部署改动**——主题纯粹是客户端表现层。
- **依据**:`.superpowers/sdd-p13/FINDINGS-survey.md`(7 轮 spike 实测归档,gitignored)。本 spec 的**每个数字都出自该文件**,不得凭推断增删。
- **前置**:P12 已闭合(crearte `e59a171`)。本批**必须保持 P12 的 12 条 responsive 守卫腿与 `AppHeader.test.ts` 的 F3 形态钉桩全绿**。
---
## 0. 目标与非目标
**目标**:为全站提供暗色主题,满足 WCAG 2.1 AA(正文 ≥4.5、大字号 ≥3.0、非文本 1.4.11 ≥3.0),保留 neo-brutalist 的「墨与纸 + 硬阴影」设计身份,并把配色约束从口头纪律变成**双主题可执行守卫**。
**非目标**:不做 SSR/预渲染、不做 per-route 主题、不做用户自定义配色、不改品牌标识(logo 与 wordmark 的形态与配色语义)、不动 `COVER_COLORS` 生成色板(实测主题无关,见 FINDINGS 第 0 轮)。
---
## 1. 现状(全部实测,非推断)
### 1.1 令牌架构支持运行时翻转(方案前提,spike 0)
Tailwind v4 把色令牌输出到 `:root,:host{--color-paper:#f7f2e7;…}`,而 utility **引用 var 而非内联 hex**:
```css
.bg-paper{background-color:var(--color-paper)}
.text-ink{color:var(--color-ink)}
.border-ink{border-color:var(--color-ink)}
```
构建产物 `dist/assets/main-*.css`(32726 B)实测:`var(--color-*)` 引用 **64 处**;内联 hex 仅 `#141414` **11 处**(5 个 `--shadow-hard*` 令牌 + wordmark 关键帧)、`#e8552f` 3 处、`#f7f2e7` 2 处、`#f5c518` 1 处、`#ffffff` 0 处。
→ **运行时令牌翻转方案成立**;需要处理的是那 11+3 处内联 hex(阴影令牌,见 D-D)与三处组件级硬编码(见 §1.3)。
### 1.2 硬编码破面(暗色下不会翻转的地方)
| # | 位置 | 现状 | 暗色下的后果 |
|---|---|---|---|
| 1 | `main.css:19-23` 五个 `--shadow-hard*` | 内联 `#141414` ×3 / `#e8552f` ×2 | 硬阴影在深底上**隐形** → neo-brutalist 身份丢失 |
| 2 | `main.css:44` `html { color-scheme: light }` | 硬编码 light | 原生控件(`<select>`、`<input>`、滚动条)不翻转 |
| 3 | `StatePanel.vue:19,20,21,30,32,33,34` | `bg-[#EFE9DA]` ×7 | 骨架屏在暗色下是**七块亮色斑** |
| 4 | `FilterDrawer.vue:32` | `backdrop:bg-ink/60` | 见 §1.4,遮罩**泛白** |
| 5 | `index.html:7,8` | `color-scheme` / `theme-color` meta 硬编码 light / `#F7F2E7` | 浏览器 UI 与首屏不跟随 |
### 1.3 配色配对的真实站点分布(决定方案选型)
grep 实证的配对用量(**这是方案 B 胜出的依据**):
| 配对 | 站点数 | 说明 |
|---|---|---|
| `bg-highlight` + 文字为 `ink`(显式或继承) | **29** | 8 处 error alert、markdown `strong`、`::selection`、toast info、FilterSidebar/DocSidebar/BaseSelect/BaseTabs 选中态、GameCard 角标、AppHeader sticker、skip-link |
| `bg-accent-ink` + `text-paper` | **11** | badge / 按钮 / error toast |
| `bg-success` + `text-paper` | 1 | toast success |
| `bg-ink` + `text-paper` | 多处 | `.btn-ink`、markdown `th`、logo |
| `text-accent-ink` on paper | 多处 | 链接 |
| `.wordmark-label` = `accent-ink` on `highlight` | 1 | 品牌标签 |
| `bg-accent` + `text-ink` | **1** | `ResultMeta.vue:52` 折叠角标(`app/` 下唯一把 accent 当背景用的地方) |
| `bg-accent` + `text-paper` | **1** | ⚠️ **终审 N1 补录(2026-10-03)**:`runtime/host/GameHost.vue:39` 的「已降级外链」徒标(渲染于 `:90`,11px bold)。spike 0 的 grep 只扫了 `app/`、**漏了 `runtime/`**(GameHost 与 `app` 同级,经 `app/views/GameView.vue:120 <GameHost>` 在 SPA 路由内、受暗色块作用域)。亮色 `paper on accent` = **3.2590 < 4.5 FAIL**(暗色 7.1218 PASS)——**P9-B 以来就存在的 pre-existing 违规,P13 未使其变差**,且不在 P13 范围(P13 是暗色主题,非修所有既有亮色 WCAG)。已登记挂账(可访问打磨批);`contrast.test.ts` 的 AA_PAIRS **不含** `paper on accent`,故该配对目前无守卫 |
| `bg-info` | **0** | info 令牌无背景用途(`text-info` 亦 0) |
### 1.4 暗色下的两个**必然**缺陷(若照抄亮色语义)
**① 亮色块上的深字失效。** 暗色下 `ink` 变浅(`#f7f2e7`),若 `highlight` 仍是亮黄 `#f5c518`,则 `ink on highlight` = **1.46 ❌**(29 处站点全废)。实测反面:`ink on accent` 暗色 = **2.31 ❌**(亮色侥幸 5.06 ✅)。
**② 遮罩泛白。** `backdrop:bg-ink/60` 的产物是 `color-mix(in oklab, var(--color-ink) 60%, transparent)`,暗色下实测解析为 **`oklab(0.962022 0.00101227 0.0155178 / 0.6)`** —— L≈0.96 即近白,遮罩失去遮罩功能。
### 1.5 header 像素预算(主题开关的落点约束,spike 1–3)
P12 spike 17 已测得 @320px 登录态 summary 右缘 = 311px。本轮实测 baseline 几何(@320px 登录态 ascii60 `/account`):
```
inner=288 a(logo)=77 + nav=74 + details=96 + gaps(24×2)=48 = 295 > 288 slack = −7
```
即**该格已无收缩余量**。注入 32px 开关后 `docOverflow = +47` ❌(P12 的 12 条守卫腿会全红)。四格实测:
| 格 | 注入 32px 开关后 docOverflow |
|---|---|
| @320 登出态 | ✅ 0(flex 收缩吸收) |
| @320 登录态 ascii60 | **❌ +47** |
| @375 登出态 | ✅ 0 |
| @375 登录态 ascii60 | ✅ 0 |
**唯一失败格 = @320px + 登录态 + 60 字长名**(正是 P12 守卫矩阵的最坏格)。
**关键约束**:`AppFooter.vue` 实测**无任何导航**(全文只有两行文字 span)。→「移动端隐藏 header nav」会让手机用户彻底失去导航,**不可行**。
### 1.6 守卫的既有缺陷(P12「守卫自身要受同等审视」同类)
`app/lib/contrast.test.ts` 的 `parseTokens()` 用**单个全局 Map + 全局正则**:
```ts
const re = /--color-([\w-]+):\s*(#[0-9a-fA-F]{6})/g
for (const m of css.matchAll(re)) map.set(m[1], m[2])
```
加入暗色块后 `matchAll` 会同时命中 `@theme`(亮)与 `html[data-theme="dark"]`(暗),`map.set` **后写覆盖** → 九个 `REQUIRED_KEYS` 全解析成暗色值,而断言标签仍写「light palette」→ 守卫**静默变成只守暗色、完全不守亮色**。
这是**假信心**,与 P12 的「注释掉包裹层守卫仍绿」、P11-N6 的「恒不匹配的死断言」同一类。必须在加暗色块**之前**重构(见 T1)。
---
## 2. 决策表
| # | 决策 | 选型 | 依据(FINDINGS 轮次) |
|---|---|---|---|
| **D-A** | 配色策略 | **方案 B:亮块反向翻转**。文字恒用 `ink`/`paper`(随主题翻),语义块反向翻转以维持对比 | 第 7 轮。迁移 **3 处** usage vs 方案 A(新增 `on-bright` 令牌)的 **32+ 处**;29 处 `bg-highlight`、12 处 `bg-accent-ink text-paper`、wordmark、`::selection`、`strong`、toast、`.btn-ink` **全部零改动** |
| **D-B** | 暗色调色板 | 见 §3.1 全量 12 令牌 | 第 7 轮:双主题 **12 条真实配对全 PASS**,零 FAIL(⚠️ 原写 14 为计数笔误,终审 N5:§5-T1.3、FINDINGS 第 7 轮配对表、代码 AA_PAIRS 三方一致为 **12**) |
| **D-C** | 暗色块选择器 | **必须 `html[data-theme="dark"]`**(特异性 (0,1,1))。**禁用**裸 `[data-theme="dark"]` | 第 4 轮:Tailwind 输出到 `:root,:host`((0,1,0))。裸属性选择器同特异性,实测虽生效但**靠源码顺序取胜**,Tailwind 层顺序一变即失效 |
| **D-D** | 阴影令牌 | 五个 `--shadow-hard*` 的内联 hex → `var(--color-ink)` / `var(--color-accent)`。**无需** per-utility 暗色覆盖规则 | 第 6 轮决定性实测:源码改 var + **真重建**后,暗色下 `.shadow-hard` 的 `box-shadow` 实测变 `rgb(247,242,231)`、`shadow-hard-accent` 变 `rgb(255,122,77)` → `--tw-shadow` 保留 var 引用,硬阴影自动跟随 |
| **D-E** | scrim | 新增 `--color-scrim: #0d0b08`,**两主题同值、不参与翻转**;`FilterDrawer` 的 `backdrop:bg-ink/60` → `backdrop:bg-scrim/80` | 第 4 轮:暗色下 `color-mix(...var(--color-ink)...)` 解析为 `oklab(L=0.962)` **泛白** |
| **D-F** | scrim 分离判据 | 分离由哪一侧提供是**主题相关**的,故守卫**按主题钉实际分离侧**:亮色 = **填充侧** `paper`(17.60;边框侧 `ink` 仅 1.07)、暗色 = **边框侧** `ink`(17.60;填充侧 `paper` 仅 1.07,分离靠 `border-t-[3px] border-ink`)。两侧阀值均 ≥ 3 | 第 7 轮 + **审查 re-pin**。暗色钉边框侧与本设计语言一致(实测亮色 `surface(#ffffff) vs paper(#f7f2e7)` 仅 **1.1165**、暗色 `surface(#221e18) vs paper(#17140f)` 仅 **1.1080**,而卡片边界仍清晰,靠 2px ink 边框)。⚠️ 原写「P12 已实测…仅 **1.06**」不可复现且 P12 文档查无出处(终审 N7)——控制者全仓 `grep -F '1.06'` 仅命中本 spec 与 FINDINGS 自己,实测值实为 1.1165,故已纠正;该数字不影响 D-F 决策方向(1.06 还是 1.12 都 <3,都靠边框分离)。⚠️ **不得写成 `max(填充侧, 边框侧) ≥ 3`**:两侧互补,对任意 scrim 色 c 都有 `max(cr(paper,c), cr(ink,c)) ≥ √cr(paper,ink)` = √16.50 = **4.0621 > 3** → **数学恒真、永不可能失败**(node 暂力验证 50653 个采样色,亮色最小 4.0621 @#eb0541、暗色 4.0560 @#0f78d2)。实证:把亮色 scrim 改成纯白(正是 D-E 要防的泛白),填充侧 `paper vs 白` = **1.1165**(面板与遮罩真的不可辨)但 max() = 18.42 → 守卫仍绿 |
| **D-G** | skeleton | 新增 `--color-skeleton`(亮 `#EFE9DA` / 暗 `#2c2721`),`StatePanel.vue` 7 处硬编码 → `bg-skeleton` | 第 0/7 轮。装饰性(无 WCAG 要求)但须可见:vs paper 亮 1.08 / 暗 1.24 |
| **D-H** | 守卫重构 | `contrast.test.ts` 改为**按块解析**(亮/暗各自 Map、各自断言),RED 先行 | §1.6。不重构则守卫静默失效 |
| **D-I** | 开关落点 | header 行 `gap-6` → **`gap-2 sm:gap-6`**,nav `gap-4` → **`gap-2 sm:gap-4`**,开关 32px(`h-8 w-8`)追加在行尾 | 第 3 轮候选 B:最坏格 **margin=16**(= 容器 `px-4` 右内边距,内容恰好填满 inner box)。**不触碰 `max-w-[6rem]`** → P12 F3 钉桩与 12 条守卫腿原样保绿;不动 logo、不动 nav 结构;`sm:`(640px) 以上零视觉变化 |
| **D-J** | 主题状态模型 | **两态**(`light` ↔ `dark`);**首次访问跟随 `prefers-color-scheme`**;用户显式点击后持久化到 `localStorage['crearte.theme.v1']` | §4 边界:第三态「恢复跟随系统」本批**有意不做**(spike 3 证明 header 无像素容纳带标签的控件;放 footer 会把同一控件拆到两处) |
| **D-K** | 防 FOUC | `index.html` `<head>` 内**内联 pre-paint 脚本**,在 CSS 之前设置 `data-theme` 与 `theme-color` meta | 第 4 轮:SPA 首屏在 Vue 挂载前就会绘制,若等 composable 则暗色用户每次刷新闪白 |
| **D-L** | `color-scheme` | `main.css` 的 `html { color-scheme: light }` → 由 `[data-theme]` 驱动(亮 `light` / 暗 `dark`) | §1.2-2。原生控件与滚动条须随主题 |
### 2.1 被否方案(附实测理由,防止后人重提)
| 方案 | 否决理由 |
|---|---|
| **方案 A:新增 `on-bright` 令牌**(亮块两主题都保持亮,文字恒深) | 须迁移 **29 处** `bg-highlight` + wordmark + `::selection` + `strong`(第 7 轮)。方案 B 只需 3 处,且 B 的语义更简单(文字恒 `ink`/`paper`) |
| **暗色 highlight 深化到 `#5c430c` / `#4d380a`** | `ink/hl` 高达 8.31/9.98,但 `hl vs paper` 仅 1.98/1.65 → **选中态与页面底色几乎同色**,FilterSidebar/DocSidebar/BaseSelect/BaseTabs 的选中态视觉消失(第 7 轮) |
| **暗色 highlight 取 `#96701a`** | `ink/hl` = **4.06 ❌** < 4.5,8 处 error alert 正文不达标(第 7 轮) |
| **开关放 `S4`:移动端隐藏 nav** | `AppFooter.vue` 实测**无导航** → 手机用户彻底失去导航(第 1 轮) |
| **开关放 `S6`:塞进用户下拉菜单** | **登出态用户无法切换主题**(`<details>` 仅在 `user` 存在时渲染)(第 2 轮) |
| **开关放 `S5`/`S7`/`S8`:瘦身 logo** | @320 实测 `S5` **+38 ❌**;且去掉「创艺」副标损毁品牌标识(第 2 轮) |
| **`S1`:仅 `gap-6`→`gap-2`** | 达标但 **margin 仅 1px**(sumRight=319 vs 视口 320)。P12 正是因 768px 只剩 ~9px 余量引发一整轮归因调查 —— 1px 不可接受(第 2/3 轮) |
| **`S3`/`A`/`D`/`E`/`G`:缩 `max-w-[6rem]`** | 全部 margin=16 达标,但**触碰 P12 的 F3 形态钉桩**(`AppHeader.test.ts` 三处断言 + spec §5 T1 钉的形态)。候选 B 同样 margin=16 且不动钉桩 → 选 B(第 3 轮) |
| **wordmark 暗色改用 `on-bright` / 深化 highlight** | `.wordmark-label` 不声明 font-size,继承 `<h1 class="text-4xl sm:text-5xl md:text-6xl font-black">`(`LandingView.vue:47`)= 36px+/900 → **WCAG 大字号,阈值 3.0**。实测亮 3.34 ✅ / 暗 3.15 ✅(accent-ink on highlight),**零改动**。且 3.34 是现网既有值、P9-B 守卫从未钉它 → 要求 4.5 等于越界改品牌标识(第 7 轮) |
| **构建期双 CSS(两份产物切换)** | spike 0 已证 utility 全走 `var()`,运行时翻转即可;双产物会使缓存与首屏逻辑复杂化,无收益 |
| **运行时注入改阴影令牌**(我曾试过) | **方法错误**:Tailwind 把 shadow 编译成 `--tw-shadow:<构建期烘焙值>`,运行时改令牌无效(第 5 轮实测:令牌已变 `#f7f2e7` 而 `box-shadow` 仍 `rgb(20,20,20)`)。必须改源码 + 真重建(第 6 轮) |
---
## 3. 实现
### 3.1 `crearte/src/app/styles/main.css`(D-B / D-D / D-E / D-G / D-L)
**(a) `@theme` 内新增两个令牌**(亮色值),并把五个阴影令牌改为 `var()`:
```css
@theme {
--color-paper: #f7f2e7;
--color-surface: #ffffff;
--color-ink: #141414;
--color-ink-soft: #5c584d;
--color-ink-faint: #6f6a5c;
--color-accent: #e8552f;
--color-accent-ink: #c03a1b;
--color-highlight: #f5c518;
--color-info: #2b62cc;
--color-success: #1f7a4d;
/* P13 新增 */
--color-skeleton: #efe9da; /* 原 StatePanel 硬编码 bg-[#EFE9DA] */
--color-scrim: #0d0b08; /* 遮罩专用,两主题同值,不参与翻转(D-E) */
/* P13:内联 hex → var(),使硬阴影随主题翻转(D-D,spike 6 实测传导成立) */
--shadow-hard-sm: 3px 3px 0 var(--color-ink);
--shadow-hard: 4px 4px 0 var(--color-ink);
--shadow-hard-lg: 6px 6px 0 var(--color-ink);
--shadow-hard-accent: 4px 4px 0 var(--color-accent);
--shadow-hard-accent-lg: 6px 6px 0 var(--color-accent);
/* 其余(--font-*、--animate-skeleton、@keyframes)原样不动 */
}
```
**(b) 暗色令牌块**(追加在 `@theme` 之后、`@font-face` 前后均可,但**必须**用 `html[data-theme="dark"]`,D-C):
```css
/* P13 暗色调色板(D-B)。选择器特异性 (0,1,1) 必须压过 Tailwind 的 :root,:host (0,1,0)——
写成裸 [data-theme="dark"] 虽也能生效,但特异性相同、靠源码顺序取胜,脆弱(spike 4 实测)。 */
html[data-theme="dark"] {
--color-paper: #17140f;
--color-surface: #221e18;
--color-ink: #f7f2e7;
--color-ink-soft: #cbc4b5;
--color-ink-faint: #a79f8e;
--color-accent: #ff7a4d;
--color-accent-ink: #ffb59a;
--color-highlight: #8a6412;
--color-info: #7aa7f0;
--color-success: #2fa46a;
--color-skeleton: #2c2721;
--color-scrim: #0d0b08; /* 与亮色同值:遮罩不翻转(D-E) */
color-scheme: dark; /* D-L:原生控件与滚动条随主题 */
}
```
**(c) `@layer base` 的 `color-scheme` 改为属性驱动**(D-L):
```css
@layer base {
html { color-scheme: light; } /* 保留:默认亮色 */
html[data-theme="dark"] { color-scheme: dark; } /* 新增;若已写在暗色块内则此处不必重复,二选一,实现者按实测择一并在注释说明 */
/* 其余(border-radius 归零、::placeholder、::selection、:focus-visible、dialog overflow)原样不动 */
}
```
> **注意**:`::selection { background: var(--color-highlight); color: var(--color-ink); }` 与 `.markdown-body strong`(`main.css:169` 附近)、`.wordmark-label`(`:107` 附近)**全部零改动** —— 方案 B 的核心收益(D-A)。实测双主题:`ink on highlight` 亮 11.30 / 暗 4.81 ✅;`accent-ink on highlight`(wordmark)亮 3.34 / 暗 3.15 ✅(大字号阈值 3.0)。
**(d) wordmark 关键帧里的 `rgb(20 20 20 / 0)`**(`box-shadow: 0 0 0 0 rgb(20 20 20 / 0)`):这是**全透明**起始值,颜色不可见,**无需改动**。若实现者改为 `var()` 亦可,但不得改变其透明语义。
### 3.2 `crearte/src/app/components/ResultMeta.vue:52`(D-A 迁移 1/3)
```diff
- class="ml-0.5 inline-flex h-5 min-w-5 items-center justify-center bg-accent px-1 font-mono text-[0.625rem] text-ink"
+ class="ml-0.5 inline-flex h-5 min-w-5 items-center justify-center bg-accent-ink px-1 font-mono text-[0.625rem] text-paper"
```
**理由**:这是 `app/` 下**唯一**把 `accent` 当背景用的地方(spike 0 grep:`bg-accent` 排除 `bg-accent-ink` 后仅 1 处)。迁移后 `accent` 在 `app/` 内成为**纯非文本令牌**(焦点环 + h3 左边框 + li 圆点),语义干净。
> ⚠️ **本节原文有误,已由全分支终审 N1 发现并经控制者独立重算证实(2026-10-03)**:原文写「全仓**唯一**」「迁移后 `accent` 成为**纯非文本令牌**」——**两句均为假**。`runtime/host/GameHost.vue:39/47/122` 有 **3 处 `bg-accent` 活引用**(base `e59a171` 既有、P13 未触碰),其中 `:39→:90` 是 `bg-accent text-paper` 的**文本徒标**(11px bold < 18.66px → 阈值 4.5)。根因:spike 0 的 grep 与 `noHardcodedColor` 守卫都只覆盖 `app/`,**漏了与 `app` 同级的 `runtime/`**。
> **后果**:① accent 迁移后**并非**纯非文本令牌;② 一条真实 WCAG 违规无守卫覆盖:亮色 `paper on accent` = **3.2590 < 4.5 FAIL**(暗色 7.1218 PASS)。该违规自 P9-B 就存在(旧 AA_PAIRS 钉的是 `ink on accent`,也非 `paper on accent`),**P13 未使其变差**,不属 P13 范围。
> **已处置**:① 本节与 §1.3 配对表已纠正(见上);② GameHost 亮色徒标 3.26 已登记为挂账(pre-existing WCAG 违规,归可访问打磨批,**不在 P13 合并前修**以免扩大已审 diff);③ `noHardcodedColor` 扫描根已扩到 `app/` + `runtime/`(commit `bb2cb42`,N4)。
> **教训**:「全仓唯一」这类全称声明必须用**全仓范围**的 grep 支撑,不能用子目录的 grep 得出。spike 0 的盲区直接导致了 spec 事实错误 + 守卫覆盖缺口两层后果。反面实测:若不迁移,暗色 `ink on accent` = **2.31 ❌**(亮色 5.06 侥幸过)。改用 `accent-ink`/`paper` 后与其他 11 处 badge 统一,双主题 `paper on accent-ink` = 亮 4.87 / 暗 10.78 ✅。
**注意**:`ResultMeta.vue:22` 的 `relative min-w-0`(P12 T3 的缺口 C 修复)**不得改动**。
### 3.3 `crearte/src/app/components/StatePanel.vue`(D-G 迁移 2/3)
7 处 `bg-[#EFE9DA]` → `bg-skeleton`(行 19/20/21/30/32/33/34)。**其余(`border-2 border-ink`、`animate-skeleton`、`aspect-video`、`shadow-hard`)原样不动。**
### 3.4 `crearte/src/app/components/FilterDrawer.vue:32`(D-E 迁移 3/3)
```diff
- class="m-0 mt-auto max-h-[85vh] w-full max-w-none overflow-y-auto border-t-[3px] border-ink bg-paper backdrop:bg-ink/60"
+ class="m-0 mt-auto max-h-[85vh] w-full max-w-none overflow-y-auto border-t-[3px] border-ink bg-paper backdrop:bg-scrim/80"
```
**理由**:spike 4 实测暗色下 `color-mix(in oklab, var(--color-ink) 60%, transparent)` → `oklab(L=0.962)` **泛白**,遮罩失效。`scrim` 是固定深色(两主题同值),故两主题都正常遮罩。**透明度从 60% 提到 80%**:`scrim`(#0d0b08) 比 `ink`(#141414) 更深,80% 保持亮色下的既有观感强度(spike 4:亮色 `paper vs scrim` = 17.60,比原 60% ink 叠纸更深,层次更强,非回归)。
> ⚠️ **原引数字已纠正(终审 N6,2026-10-03)**:原文写「近似实色 **5.12**」,但该值**不可复现**——控制者用三种混合法独立重算 `60% ink(#141414) over paper(#f7f2e7)` 的 `cr(paper, ·)`:gamma 空间线性混合 = **4.6292**、线性光(物理正确)= **2.2842**、`color-mix(in oklab, ink 60%, paper)`(Tailwind 实际产出)= **5.3691**,无一种得 5.12。原数字未注明算法且无法复现,故删除具体值、只保留方向结论(scrim 80% 混合后 cr=11.64 确实比 ink 60% 更深,非回归,该结论不受影响)。**教训:spec 里每个实测数字都要么注明算法、要么可被守卫/测试复现;不可复现的孤立数字会被终审当缺陷抓出来。**
> 若实现者实测认为 80% 在亮色下过重,可回调至 70%,但**必须在报告里附两主题的实测截图或计算值**,不得凭感觉。
### 3.5 `crearte/src/app/composables/useTheme.ts`(D-J,新增)
沿用 `app/lib/recent.ts` 的 localStorage 容错范式(配额/隐私模式写失败静默、损坏值读回默认)。
```ts
import { ref, type Ref } from 'vue'
export type Theme = 'light' | 'dark'
export const THEME_KEY = 'crearte.theme.v1'
/** 读持久化选择;缺失或损坏 → null(表示「跟随系统」)。 */
export function readStoredTheme(): Theme | null {
try {
const v = localStorage.getItem(THEME_KEY)
return v === 'light' || v === 'dark' ? v : null
} catch {
return null
}
}
/** 系统偏好;不支持 matchMedia 的环境(含 happy-dom 部分场景)→ 'light'。 */
export function systemTheme(): Theme {
try {
return typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
} catch {
return 'light'
}
}
/** 把主题落到 <html data-theme> 与 theme-color meta(D-K 的内联脚本做首屏,本函数做后续切换)。 */
export function applyTheme(theme: Theme): void {
document.documentElement.setAttribute('data-theme', theme)
const meta = document.querySelector('meta[name="theme-color"]')
if (meta) meta.setAttribute('content', theme === 'dark' ? '#17140f' : '#f7f2e7')
}
// 模块级单例(与 useToast 同范式)。⚠️ P9-B D-I 教训:单例须配 __resetTheme() 供测试隔离。
const theme: Ref<Theme> = ref(readStoredTheme() ?? systemTheme())
export function useTheme() {
function toggle(): void {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
try {
localStorage.setItem(THEME_KEY, theme.value)
} catch {
/* 配额/隐私模式:静默,主题本次会话内仍生效 */
}
applyTheme(theme.value)
}
/** 当前是否为用户显式选择(false = 仍在跟随系统)。供 aria-label 措辞与测试用。 */
const isExplicit = () => readStoredTheme() !== null
return { theme, toggle, isExplicit }
}
export function __resetTheme(): void {
theme.value = readStoredTheme() ?? systemTheme()
}
```
**要求**:
- `theme` 的**初始值**必须与 `index.html` 内联脚本的判定逻辑一致(同一优先级:stored → system → light),否则首屏与挂载后会闪一下。
- `applyTheme` 须在 composable 首次被使用时调用一次(`App.vue` 的 `onMounted` 或 `useTheme()` 内),以覆盖内联脚本未执行的场景(如 e2e 直接注入 DOM)。
- 不做 `matchMedia` 的 `change` 监听(用户已显式选择后系统切换不应覆盖;未显式选择时刷新即跟随)——**在代码注释里写明这是有意的**。
### 3.6 `crearte/src/index.html`(D-K)
```diff
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <meta name="color-scheme" content="light" />
- <meta name="theme-color" content="#F7F2E7" />
+ <meta name="color-scheme" content="light dark" />
+ <meta name="theme-color" content="#F7F2E7" />
<meta name="description" content="crearte 创艺——互动小说与浏览器小游戏托管社区,收录可直接游玩的作品目录。" />
<title>crearte 创艺</title>
+ <!-- P13 D-K:pre-paint 主题脚本,必须在 CSS 与 Vue 之前执行,否则暗色用户每次刷新闪白。
+ 判定优先级与 useTheme.ts 逐字一致:stored → prefers-color-scheme → light。 -->
+ <script>
+ (function () {
+ try {
+ var stored = localStorage.getItem('crearte.theme.v1')
+ var theme =
+ stored === 'light' || stored === 'dark'
+ ? stored
+ : window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
+ ? 'dark'
+ : 'light'
+ document.documentElement.setAttribute('data-theme', theme)
+ var meta = document.querySelector('meta[name="theme-color"]')
+ if (meta) meta.setAttribute('content', theme === 'dark' ? '#17140f' : '#f7f2e7')
+ } catch (e) {
+ document.documentElement.setAttribute('data-theme', 'light')
+ }
+ })()
+ </script>
</head>
```
> `<script>` 必须放在 `<head>` 内、且在 `<body>` 之前(现结构已满足:body 里才有 `<script type="module">`)。用 `var` 与 IIFE,不依赖 ES module 时序。**CSP 注意**:本批不引入 CSP(P11 已判定需独立设计批),内联脚本当前可用;若将来上 CSP,此脚本需 `nonce` 或外置——**在脚本注释里留一句提示**。
### 3.7 `crearte/src/app/components/AppHeader.vue`(D-I)
**(a) 容器行与 nav 的 gap**:
```diff
- <div class="mx-auto flex h-14 w-full max-w-6xl items-center gap-6 px-4">
+ <div class="mx-auto flex h-14 w-full max-w-6xl items-center gap-2 px-4 sm:gap-6">
```
```diff
- <nav class="flex gap-4 text-sm font-bold">
+ <nav class="flex gap-2 text-sm font-bold sm:gap-4">
```
**(b) 主题开关**:追加在 header 行**末尾**(`<template v-if="authEnabled">` 之后,即 `</div>` 之前):
```vue
<button
type="button"
data-testid="theme-toggle"
class="flex h-8 w-8 shrink-0 items-center justify-center border-2 border-ink bg-surface text-sm font-bold"
:aria-label="themeLabel"
:title="themeLabel"
@click="toggle"
>
<span aria-hidden="true">{{ theme === 'dark' ? '☀' : '☾' }}</span>
</button>
```
`<script setup>` 内:
```ts
import { useTheme } from '@/composables/useTheme'
const { theme, toggle } = useTheme()
const themeLabel = computed(() =>
theme.value === 'dark' ? '切换为亮色主题(当前:暗色)' : '切换为暗色主题(当前:亮色)'
)
```
**约束(必须逐条满足,均有 spike 依据)**:
- 开关尺寸**必须** `h-8 w-8`(32px)。spike 3 实测 28px(`h-7`)在最坏格 margin 仅 5px(过紧),32px 为 16px。
- **不得**改动 `<summary>` 的 `max-w-[6rem] min-w-0` 与内部两 span 结构(P12 F3 钉桩 + `AppHeader.test.ts` 三处断言 + `responsive.spec.ts` 的 caret/accname 腿)。
- **不得**改动 logo 的文字与字号(spike 2 的 S5/S7/S8 瘦身方案已否)。
- `shrink-0` 必须有,否则开关会被 flex 压缩。
- 图标字形(☀/☾)包 `aria-hidden`,可访问名由 `aria-label` 提供 —— 与 P9-B 的星形按钮、P12 的 caret 同一范式。**`aria-label` 必须同时说明当前态与动作**(两态开关无可见标签,AT 用户须能听到状态)。
**(c) 布局余量核对**(实现后必须实测,不得凭算术):spike 3 已测 @320px 登录态 ascii60 在此方案下 `margin=16`、`docOverflow=0`;@375/@320-short/@375-short 全 `margin=14~16`。**实现后由 `responsive.spec.ts` 既有 12 腿 + T5 新增暗色腿复验。**
### 3.8 `crearte/src/app/App.vue`
在 `<script setup>` 中调用一次 `useTheme()` 并 `applyTheme(theme.value)`(或 `onMounted`),确保内联脚本未生效的场景(e2e 直接操作 DOM、SSR-less 的极端时序)也能落地主题。**不改模板结构**(skip-link 仍是根 div 首子,P9-B 钉桩)。
---
## 4. 边界(不做的事,附理由)
| 不做 | 理由 |
|---|---|
| **第三态「恢复跟随系统」** | spike 3 证明 header 在 @320px 最坏格无像素容纳带标签的控件;放进 footer 会把同一控件拆到两处;三态循环(light→dark→system)在 32px 无标签图标按钮上不可发现,且「暗色回亮色要点两次」是已知反模式。**登记为打磨批候选**。 |
| **per-route / per-组件主题** | 无需求;令牌是全局的,局部覆盖会破坏对比守卫的可判定性 |
| **用户自定义配色 / 色板选择器** | 超出本批;且自定义色无法保证 WCAG,等于拆掉守卫 |
| **SSR / 预渲染 / OG 卡片** | OG 是独立挂账批(需服务端注入或预渲染,nginx 当前无 `try_files` 配置面) |
| **改 `COVER_COLORS` 或 `GameCover` 的 `text-white`** | spike 0 实测:inline style + 8 色固定表,`text-white on` 全部 ≥4.72(且字为 `text-4xl/6xl font-black` 大字号,阈值 3.0)→ **主题无关,零改动** |
| **改品牌标识(logo 文字/字号、wordmark 形态与配色)** | wordmark 是 WCAG 大字号(阈值 3.0),实测亮 3.34 / 暗 3.15 双达标;3.34 是现网既有值且 P9-B 守卫从未钉它 → 改它属越界 |
| **移动端隐藏 header nav / 汉堡菜单** | `AppFooter.vue` 实测无导航 → 隐藏即失去导航。P12 已证伪汉堡菜单的必要性(nav 内容宽 74–158px、320–1280px 零溢出) |
| **D4:nav 链接逐字换行** | P12 已 park 待设计决策(纯外观、无溢出)。本批的 `gap-2 sm:gap-4` 会让 @320px 的 nav 从 74px 降到 59px(换行数变化),**这是有意的**:spike 3 实测该形态 margin=16 且不触碰 P12 钉桩。逐字换行本身仍属外观问题,不在本批处理 |
| **CSP / HSTS** | P11 已判定:CSP 需独立设计批(游玩子域经 iframe+SW 加载用户作品),HSTS 待 TLS 批 |
| **后端 / 部署改动** | 主题纯客户端表现层。`crearte-server` 与 `crearte-deploy` **零改动** |
---
## 5. 测试计划
### T1(守卫重构,**必须第一个做,RED 先行**)—— `app/lib/contrast.test.ts`(D-H)
1. **重构 `parseTokens()` 为按块解析**:返回 `{ light: Map, dark: Map }`。亮色块 = `@theme { … }` 内的 `--color-*`;暗色块 = `html[data-theme="dark"] { … }` 内的 `--color-*`。**解析失败要红得明确**(沿用现有 `throw new Error(...)` 范式,不许静默跳过)。
2. **两块各自断言** §3.1(b) 的 12 个令牌齐备(`paper`/`surface`/`ink`/`ink-soft`/`ink-faint`/`accent`/`accent-ink`/`highlight`/`info`/`success`/`skeleton`/`scrim`)。
3. **双主题各断言 §1.3/§7 的真实配对表**(12 条),阈值按 WCAG 正确取值:正文 4.5、大字号 3.0(wordmark)、非文本 3.0(焦点环)。
4. **`scrim` 的分离判据 = 按主题钉实际分离侧**(D-F):亮色钉 **`paper vs scrim ≥ 3`**(填充侧,实测 17.60)、暗色钉 **`ink vs scrim ≥ 3`**(边框侧,实测 17.60)。
> **⚠️ 本节经两次纠正(2026-10-03),第二次是对第一次的修正。**
> **原文(错)**:「`ink vs scrim ≥ 3` **两主题**」。但亮色 `ink(#141414) vs scrim(#0d0b08)` 实测只有 **1.07**(两个深色互不分离)——照字面写死会让**亮色腿误红**。由实现者发现、控制者独立复现。
> **第一次纠正(仍错)**:改为 `max(填充侧, 边框侧) ≥ 3`。控制者当时**只验了「会不会误红」(不会),没验「有没有牙」**——而它恒真:两侧互补,`max` 的理论下界 = `√cr(paper,ink)` = **4.0621 > 3**,对**任意** scrim 取值都不可能失败。实证:亮色 scrim 改纯白(= D-E 要防的泛白缺陷)时填充侧得 1.1165、守卫仍绿。由**任务级审查 finding #1** 发现(M1:亮 scrim 改 `#ffffff` → 绿 32/32),控制者用暂力重算独立证实。
> **第二次纠正(现行)**:拆成按主题钉**具体那一侧**。白 scrim 得 1.1165 < 3 → 红,牙口恢复(mutation 已实测)。
> **教训(两层,均源于 P12「守卫自身要受同等审视」)**:① 控制者给守卫写了不成立的断言;② **纠正时只验了一个失效方向**——守卫有两种失效:「误红」(假阳性)与「恒绿」(假阴性)。第一次纠正只排除了前者。**修守卫时必须两个方向都验:对合法值不误红 + 对非法值必红(mutation)。** 另:`max(a,b) ≥ k` 形态的断言是恒真高发区——当 a、b 互补时 max 有非平凡下界,阀值低于该下界就永远不失败。
5. **RED 证明牙口**(在加暗色块**之前**先写测试):
- 暗色块缺失 → 「暗色 12 令牌齐备」红
- 把暗色 `highlight` 临时改成 `#f5c518`(亮黄)→ `ink on highlight` 暗色 = **1.46** 红
- 把暗色 `accent` 改回 `#e8552f` → 焦点环仍过,但**反面**:若同时把 ResultMeta 迁移回退,`ink on accent` 暗色 = 2.31(此条由 T3 的组件测试覆盖,守卫层记录数值即可)
- **旧解析器回归证明**:用重构前的单 Map 逻辑跑含暗色块的 CSS,断言其结果**错误**(九键全为暗色值)—— 这条测试本身就是 D-H 缺陷的钉桩,防止后人「简化」回单 Map
6. **mutation 自查**(实现者必做并附输出):把亮色 `success`(现行 `#1f7a4d`,`paper on success` = **4.7628**)改成任意低对比值 → 亮色腿必须红。**证明重构后亮色仍被守**(这是 D-H 的核心风险)。实现者实际用 `#cccccc` → 实测 **1.4384 红**;控制者复跑同值同结果。
> ⚠️ **本条原文有误(终审 N8,2026-10-03 纠正)**:原文写「把亮色 `success` 改回 P9-B 前的 `#2fa46a` 之前的值 `#2fa46a` → 应仍过(4.76)」——**措辞自相矛盾**(同一值既是「之前的值」又是它自己),且 **`#2fa46a` 是暗色** success 令牌值;把**亮色** success 改成它会得 `paper(#f7f2e7) on #2fa46a` = **2.8331 < 4.5 → 红**,不是「仍过」;4.76 实属现行亮色 `#1f7a4d`。实现者未受影响(它用的是 `#cccccc`),仅本说明文字错。
### T2(令牌层)—— `main.css`(D-B/D-D/D-E/D-G/D-L)
- 按 §3.1 落地。T1 的守卫应从红转绿。
- **构建产物核验**(`npm run build` 后 grep dist CSS):
- `var(--color-*)` 引用数 **≥ 64**(不得下降)
- 内联 `#141414` 从 **11 降到 ≤ 3**(余下的是 wordmark 关键帧透明值等非令牌处;实现者须逐处说明剩余来源)
- 暗色块出现在产物中且**未被 Tailwind 层吞掉**
- `.shadow-hard` 的 `--tw-shadow` 含 `var(--color-ink)`(spike 6 的传导前提)
> **⚠️ grep 命令必须容忍 minifier 去引号(实现者发现,控制者独立复现,2026-10-03)**:本 spec 与 plan 原先写 `grep -c 'html\[data-theme="dark"\]'`(带双引号)。但 lightningcss 的 minifier **会去掉属性选择器里非必需的引号**,实际产物是 `html[data-theme=dark]`。用带引号的字面命令 grep 产物 → **返回 0,会被误判为「暗色块被 Tailwind 吞掉」**。
> 正确核验:`grep -c 'html\[data-theme="\?dark"\?\]' "$CSS"` 或去引号版,须 ≥ 1。**源码里必须写带引号的 `html[data-theme="dark"]`(CSS 源码选择器)是对的;错的只是产物核验的 grep 模式。** 实测:带引号 0 / 去引号 1,暗色块逐字完整 `html[data-theme=dark]{--color-paper:#17140f;…;color-scheme:dark}`。
- **禁止**用 `@apply` 或 `!important` 绕过特异性问题(D-C)。
### T3(三处 usage 迁移)—— §3.2 / §3.3 / §3.4
- `ResultMeta.vue:52`:`bg-accent text-ink` → `bg-accent-ink text-paper`。**须新增/更新组件测试**断言该角标的类名(防止回退;spike 反面:暗色 2.31 ❌)。
- `StatePanel.vue` ×7:`bg-[#EFE9DA]` → `bg-skeleton`。新增源码级断言(可加进 `tableOverflow.test.ts` 同族的守卫,或新建 `app/lib/noHardcodedColor.test.ts`):**扫描 `app/**/*.vue`,禁止 `bg-[#`/`text-[#`/`border-[#` 形态的硬编码 hex**。RED 先行(迁移前应报 7 处违规)。
- ⚠️ 该守卫须沿用 P12 `tableOverflow.test.ts` 的**屏蔽范式**:`<script>`/`<style>`/HTML 注释/`<textarea>`/`<title>` 内容替换为等长空白后再扫描,否则注释里的示例会误报(P12 FE-1 的假绿教训同源)。
- `FilterDrawer.vue:32`:`backdrop:bg-ink/60` → `backdrop:bg-scrim/80`。
### T4(主题机制)—— `useTheme.ts` + `index.html` + `App.vue` + `AppHeader.vue`
- **`useTheme.test.ts`**(新增,沿用 `useToast.test.ts` 的隔离范式,用 `__resetTheme()`):
- stored=`dark` → 初始 `dark`;stored=`light` → `light`;stored 缺失 → 跟随 `matchMedia`;stored 为垃圾值(`'neon'`)→ 跟随系统
- `toggle()` 翻转、写 localStorage、调 `applyTheme`(`document.documentElement.dataset.theme` 与 `meta[name=theme-color]` 都变)
- localStorage 抛错(配额/隐私模式,`vi.spyOn(...).mockImplementation(() => { throw … })`)→ **静默**,主题仍在会话内生效
- `matchMedia` 不存在 → 回落 `light`,不抛错
- `isExplicit()`:未点击过 → false;点击后 → true
- **`AppHeader.test.ts`**(扩展,**既有断言零删除**):
- 开关存在、`data-testid="theme-toggle"`、`aria-label` 含当前态与动作、图标 span 为 `aria-hidden`
- 点击 → `useTheme().theme` 翻转(mock 或真实 store + `__resetTheme()`)
- **P12 的 F3 形态钉桩必须仍绿**:`max-w-[6rem]` + `flex` + `min-w-0` + 两 span 结构 + `:title`
- gap 变更钉桩:容器行含 `gap-2 sm:gap-6`、nav 含 `gap-2 sm:gap-4`
- **`index.html`**:内联脚本的判定优先级须与 `useTheme.ts` **逐字一致**。加一条源码级守卫(可并入 T3 的新守卫文件或 `contrast.test.ts` 同族)断言两处优先级串一致,或至少在测试里注释指明「改一处必须改另一处」并加断言比对 `THEME_KEY` 字面量出现在 `index.html` 中。
- **`App.vue`**:调 `applyTheme`;**模板结构不得改**(skip-link 仍首子,P9-B 钉桩)。
### T5(e2e)—— `e2e/dark.spec.ts`(新增)+ `e2e/responsive.spec.ts`(**既有 12 腿必须保持绿**)
`dark.spec.ts` 最低覆盖(复用 `helpers.ts` 的 `seedSession(page, role, displayName)`):
1. **默认跟随系统**:`page.emulateMedia({ colorScheme: 'dark' })` + 清空 localStorage → 首屏 `html[data-theme]` = `dark`(**且必须在 Vue 挂载前就已设置**:用 `addInitScript` 或 `goto` 后立刻断言,验证 D-K 的 pre-paint 无 FOUC)
2. **开关切换**:点击 → `data-theme` 翻转、`meta[name=theme-color]` 同步、`localStorage['crearte.theme.v1']` 写入
3. **持久化**:切换后 reload → 主题保持(不闪回)
4. **计算值实证**(不只查属性):暗色下 `getComputedStyle(document.body).backgroundColor` = `rgb(23, 20, 15)`、`color` = `rgb(247, 242, 231)`;**硬阴影跟随**:某 `.shadow-hard` 元素的 `box-shadow` 含 `rgb(247, 242, 231)`(spike 6 的传导在真产物上复验)
5. **遮罩不泛白**:打开 FilterDrawer,`::backdrop` 的 `background-color` 在暗色下**不是**近白(断言其解析值的亮度低于阈值,或直接断言 `scrim` 令牌值生效)
> **⚠️ 补充(2026-10-03 审查期):只断言亮度不够,必须同时钉 alpha。** 控制者实测:兜底分支若只看最大通道,全透明的 `rgba(0,0,0,0)`(= 遮罩功能完全失效)会通过。D-E 钉的是 `backdrop:bg-scrim/80`,故 **亮度(oklab L < 0.4,失败形态 L=0.962)与透明度(alpha ≈ 0.8)两条都要断言**,且**主分支与兜底分支同等严格**——兜底分支正是为「未来浏览器改变序列化形态」而存在,它比主分支松就等于那时守卫静默失效。
> 实现期实测:Tailwind 产出 `color-mix(in oklab, var(--color-scrim) 80%, transparent)`,Chromium 对 `::backdrop` 的 `getComputedStyle` **保留 oklab 形态**(`oklab(0.150853 0.00139775 0.00721639 / 0.8)`)而非 rgb,故须按色彩空间解析。
6. **可访问名**:开关的 `toHaveAccessibleName` 含当前态(与 `a11y.spec.ts` 同一 API)
7. **零横向溢出(暗色)**:@320/@375 × 登录态 ascii60,`scrollWidth ≤ clientWidth + 1`(暗色下 header gap 变更的回归钉桩)
`responsive.spec.ts`:**既有 12 腿一字不改、必须全绿**(P12 成果)。若 gap 变更导致某腿数值变化,**不得改断言迁就**——先查明是否真溢出,是则修实现。
### 全量回归(合并前必跑,`set -o pipefail`,**禁 `--if-present`**)
| 腿 | 命令 | 基线(P12 合并态) |
|---|---|---|
| vitest | `npm run test` | **635 passed / 74 files** |
| typecheck | `npm run typecheck` | exit 0 |
| build | `npm run build` | exit 0(含 `vue-tsc --noEmit`) |
| build:runtime | `npm run build:runtime` | exit 0 |
| 主 e2e | `npm run e2e` | **92 passed + 1 skipped** |
| noauth e2e | `npm run e2e:noauth` | **4 passed** |
新增测试后数字应上升;**任何既有腿下降都必须解释**。P12 的 12 条 responsive 腿与 `AppHeader.test.ts` 的 F3 钉桩属**不得回退**项。
---
## 6. CHANGELOG 与版本
- `crearte` → **0.26.0**(minor:新特性 = 暗色主题 + 两层守卫扩展)。条目分 `Added`(暗色主题、`useTheme`、`dark.spec.ts`、硬编码色守卫)、`Changed`(阴影令牌 var 化、header gap、三处 usage 迁移)、`Tests`、`Context`。
- `crearte-server` / `crearte-deploy` → **无改动,不发版**。
- wrapper → **0.3.6**(记账:ROADMAP P13 行 + 文档索引注册本 spec 与 plan + P9-B/P11 行尾「C 暗色模式」挂账标注为已清偿)。
条目格式:同条目英文行紧跟中文行(**无空行**),不同条目间**空一行**。
---
## 7. 证据存档
`crearte/.superpowers/sdd-p13/`(`.gitignore` 已含 `.superpowers/`,不入库):
- `FINDINGS-survey.md` — **7 轮 spike 的实测输出固化**:Tailwind v4 令牌形态(运行时翻转可行性)、header 余量四格实测与唯一失败格、9 候选粗筛、以 margin≥8px 为判据的细化(候选 B 胜出)、机制验证(特异性/var 传导/color-mix 泛白)、**阴影传导的方法错误与纠正**(第 5 轮失败 → 第 6 轮源码+重建成功)、调色板锁定(highlight 深度搜索 6 候选、双主题 **12** 配对全 PASS、两个自身错误的修正)、迁移清单、守卫重构必要性。每个数字都有出处。
- `spike1-header-slack.txt` … `spike6-shadow-source.txt` — 六轮原始输出(第 5 轮的**失败**输出也保留,它是方法错误的证据)。
- spike 源文件(`e2e/_spike-p13-*.spec.ts`)跑完即删,工作树 `porcelain=0` 已核。
---
## 8. 实现者必读的纪律(延续 P9-B / P11 / P12 教训)
1. **不推断 CSS/Vue/构建行为,只实测。** 本批已有两次栽在推断上:spike 5 用运行时注入验证构建期烘焙(失败)、spike 1 第一版用 `APPLY.toString()` + `new Function` 序列化注入(`ReferenceError: APPLY is not defined`)。
2. **守卫先写、先看它红。** RED 输出必须复现本 spec 引用的实测数字(如暗色 `ink on highlight` = 1.46),否则守卫可能无牙。
3. **守卫自身要受同等审视。** 写完守卫后做 mutation:故意破坏被测属性,确认守卫变红;再确认**亮色腿在重构后仍然守得住**(D-H 的核心风险是重构把亮色守卫静默弄丢)。
4. **P12 成果不得回退。** `max-w-[6rem]`、五个表格包裹层的 `relative overflow-x-auto pr-1 pb-1`、`ResultMeta.vue:22` 的 `relative min-w-0`、`AccountView` 的 `min-w-0 wrap-anywhere`、skip-link 首子位置、`useToast` 的 `announcedId` 单调语义 —— 全部原样保留。
5. **throwaway 文件跑完即删**,且注意 `npm run build` 含 `vue-tsc --noEmit` 会检查 `e2e/*.spec.ts` 的类型(spike 6 曾因此 `BUILD_EXIT=2`);spike 期间用 `npm run build:e2e` 绕开,但**交付前 `npm run build` 必须真过**。
5b. **⚠️ 测试与注释里禁写 Tailwind 类名字面量**(实现者 T2 期发现,spec 与 FINDINGS 均未预料)。Tailwind v4 的自动内容探测**扫描全部源文件,含 `.test.ts`**:`FilterDrawer.test.ts` 里一句 `not.toContain('backdrop:bg-ink/60')` 断言(及其注释)让 Tailwind 把这个已迁移掉的类名当候选、**重新生成 utility 及其 `#14141499` fallback 烧回产物**——内联 `#141414` 因此停在 3 而非 2。修法是**用字符串拼接构造字面量**(`'backdrop:bg-i' + 'nk/60'`),使其不出现在任何源文件里。
与 P12 的 `noHardcodedColor`/`tableOverflow` 屏蔽范式**同源**(注释里的示例会污染扫描),只是方向相反:那里防「守卫扫到注释里的 table」,这里栽在「Tailwind 扫到测试里的死 utility」。**任何「某 utility 不得再出现」的回退钉桩,都必须用拼接而非字面量。**
6. **报数字要报实跑输出**,不报「应该没问题」。
7. **发现 spec 有误就纠正 spec**,不要迁就实现(P12 的实现者纠正了 768px 归因,控制者用 spike 17 独立定案其成立并 re-pin 了五处下游)。
@@ -0,0 +1,385 @@
# P14 设计:拆分 `ContentService` god object(服务架构维度)
- **日期**:2026-10-03
- **仓**:`crearte-server`(**仅此一仓**;crearte / crearte-deploy 零改动)
- **base**:`dbf7fe5`(0.17.2,master)
- **性质**:**纯结构重构,零行为变更**。验收的核心是「四门全绿 + 既有测试断言零修改 + 公开 API 语义不变」。
- **取证账本**:`crearte-server/.superpowers/sdd-p14/survey.md`(全部数字实测,含 Route C spike 输出与三次归属分析的自我纠错)
- **目标版本**:crearte-server **0.18.0**(服务层结构调整,minor);wrapper **0.3.7**
---
## §0 现状与动机
`internal/service/content.go` = **856 行 / 30 个函数**,是 `internal/service/` 唯一的异常值:
| 文件 | 行数 |
|---|---|
| **content.go** | **856** |
| content_test.go | 376 |
| account_test.go | 274 |
| auth.go | 234 |
| auth_test.go | 229 |
| validate.go | 196 |
`service/` 目录合计 3884 行,content.go 独占 **22%**;是次大生产文件 `auth.go` 的 **3.7 倍**。
它承载**五条互不相干的职责线**(目录读 / 反应 / 上传 / 投稿 CRUD / 审核发布),且:
- struct 仅 5 个字段,**全是共享仓储依赖、无可变内部状态** → 没有「必须放在一起」的状态理由;
- **五个 handler 各只用一条线**(零交叉),却各自声明对**22 个公开方法**的依赖;
- `cmd/catalog.go`(静态目录导出)**显式传 `nil` 给 bundle 参数**,注释「bundle 不参与导出(list/detail 只用 versions)」——god object 迫使一个只需 2 个方法的命令构造携带 22 方法 / 4 依赖槽的服务,并用 `nil` 占位绕开不需要的依赖;
- 单函数最大 **`Approve` 170 行**(605–774)。
**为什么现在做**:六维度循环中「服务架构」维度自 P11(服务端安全硬化)后未再触碰;P13(UI/UX)刚交付, crearte 仓空闲但本批不需要它。此项证据最强(见 §1 三条决定性发现),且**风险可被既有 11 包测试完全兜住**。
---
## §1 取证:三条决定性发现(全部实测)
### 1.1 跨职责线调用 = 7 处,**全部指向包级 helper 函数**
python AST 级扫描 `content.go` 内部调用图:同线内部调用 9 处,**跨线 7 处**,逐条:
| 调用方(线) | 被调(线) | 行 |
|---|---|---|
| `CreateBundleUpload`(upload) | `randomToken`(helper) | 239 |
| `CreateCoverUpload`(upload) | `randomToken`(helper) | 261 |
| `UpdateSubmission`(submission) | `ParseSubmissionEnvelope`(helper) | 486 |
| `Approve`(moderation) | `ParseSubmissionEnvelope`(helper) | 616 |
| `Approve`(moderation) | `versionFromUpload`(helper) | 711, 726 |
| `Approve`(moderation) | `pendingKeys`(helper) | 766 |
**零个「方法 → 方法」跨线调用**:`s.CoverURL(` / `s.BundleURL(` 在 content.go 内**零命中**(只被 handler 从外部调);`reactionTarget` 3 处全在反应线内;`checkSubmissionRules` / `checkUploadRefs` 4 处全在投稿线内。
→ **五条线只通过包级 helper 共享代码,helper 无 struct 依赖**(实测 `helper` 线用到的 struct 字段为空)。拆分后 helper 保持包级即可被各子 service 直接调用,**不产生任何跨 service 回调、不产生循环依赖**。这是可拆性的最强证据。
### 1.2 handler 依赖面 = 恰好一线,零交叉
| handler | 现构造签名 | 用的方法(实测) | 线 |
|---|---|---|---|
| `games.go:23` | `NewGames(svc *service.ContentService)` | ListPublished, GetPublishedDetail, CoverURL, BundleURL | **catalog** |
| `reactions.go:20` | `NewReactions(svc *service.ContentService)` | SetFavorite, RateWork, UnrateWork, UserReaction, MyReactions | **reaction** |
| `uploads.go:21` | `NewUploads(svc *service.ContentService, maxBundle, maxCover int64)` | CreateBundleUpload, CreateCoverUpload | **upload** |
| `submissions.go:21` | `NewSubmissions(svc *service.ContentService)` | CreateSubmission, ListMine, GetSubmission, UpdateSubmission, DeleteSubmission | **submission** |
| `admin.go:20` | `NewAdmin(svc *service.ContentService, bundle *service.BundleService)` | ListQueue, Approve, Reject, Unpublish, Republish, UpdateWorkFeatures | **moderation** |
### 1.3 构造点爆炸半径:19 个测试调用点里 **11 个只构造不调方法**
- 生产 2 处:`cmd/serve.go:64`(全量依赖 → 喂 5 个 handler)、`cmd/catalog.go:62`(**传 `nil` bundle**,只喂 `handler.NewGames`)。
- 测试 **14 文件 / 19 调用点**,其中 **11 个文件「只构造、不直接调方法」**(把 svc 交给 handler/router 走 HTTP 层测试);仅 3 个直接调:
- `content_hosted_test.go`:1 调用点、1 线(submission)
- `content_test.go`:4 调用点、2 线(catalog + moderation)
- `namespace_test.go`:2 调用点、2 线(submission + upload)
### 1.4 各线规模与依赖(实测,决定子 service 构造签名)
| 线 | 行数 | 函数数 | 用到的 struct 字段 |
|---|---|---|---|
| **catalog** | 86 | 4 | `store`, `objects` |
| **reaction** | 82 | 6(含私有 `reactionTarget`) | `store` |
| **upload** | 78 | 2 | `store`, `users`, `objects`, `bundle` |
| **submission** | 267 | 7(含私有 `checkSubmissionRules`/`checkUploadRefs`) | `store`, `objects` |
| **moderation** | 255 | 6 | `store`, `users`, `objects` |
| **helper**(包级函数) | 49 | 4 | 无 |
### 1.5 Route C spike:Go 嵌入提升满足消费方窄接口(`golang:1.24-alpine` 实测,`go vet` 净)
玩具类型与真仓同构(子 service 持子仓储接口、组合根嵌入指针、消费方定义窄接口)。四个假设**全部编译通过且运行正确**:
- **H1** 组合根嵌入 `*ReactionService` + `*UploadService`(指针字段 + 指针接收者)后,`c.SetFavorite(...)` / `c.CreateUpload(...)` 直接可用(方法提升)。
- **H2** ⭐ `var rp reactionPort = c` / `var up uploadPort = c` 成立 → **提升后的方法集使组合根满足任何窄接口**,故 19 个测试构造点与 `serve.go` 五处装配**零改动**。
- **H3** `NewReactionService(store)` 签名里根本没有 bundle → **`cmd/catalog.go` 的 `nil` 占位可消失**。
- **H4** 组合根与子 service 都能喂给收窄后的 handler → 两路线可并存、可渐进。
嵌入提升的前提在真仓复核:25 个方法名 **零重名**(`sort | uniq -d` = 0)→ 无 ambiguous selector;3 个私有方法(`reactionTarget`/`checkSubmissionRules`/`checkUploadRefs`)均只在本线内使用、不参与提升。
**仓内已有先例**(拆分与惯例一致,非外来重构口味):`NewAccountService(users, content, objects)` 与 `NewBundleService(versions, uploads, keks, activeKEK)` 都直接收**子仓储接口**而非大 store;`ContentStore` 已按职责分子仓储(`Works()`/`Versions()`/`Submissions()`/`Uploads()`/`Reactions()`);`cmd/catalog.go:79` 有 `catalogRenderer` 接口 = **消费方定义窄接口**的先例;`admin_users.go` 用自由函数。
### 1.6 拆分安全性:三个风险已实测证伪
1. **包内直接读 ContentService 私有字段** = **零**。`account.go`/`auth.go` 里的 `s.store`/`s.users` 属于**它们自己的 struct**;`AccountService` 持的是 `repository.ContentStore`(仓储接口)而非 `*ContentService`,**完全不受拆分影响**。
2. **ContentService 被 interface 化 / 类型断言 / 方法值使用** = **零**(`type ContentService struct` 全仓仅一处定义)。
3. **共享声明的包外依赖** = 10 个(见 §3-D),全部保持导出即可,无签名变更。
### 1.7 新发现:`ContentService.now` 是**死字段**
`content.go` 内 `s.now` **零引用**;全文件只有两行提到 `now`——line 31 字段声明、line 35 构造器赋值 `now: time.Now`。测试里也无 `.now =` 注入(全仓 `.now =` 只命中 `cleanup.go:26` 的 `SetNow` setter)。
对照组证明它是照抄兄弟 service 的模式而从未使用:`auth.go:159` 真读 `s.now()`、`cleanup.go` 有 `SetNow(fn)` test seam、`account.go` 也有 `now` 字段(其使用情况不属本批范围)。
→ **本批删除该死字段**。因为它在拆分爆炸半径内,不删就得决定它归哪条线。删除**不影响任何调用点**(`NewContentService` 的 4 参数签名不变,`now: time.Now` 是构造器内部赋值)。
---
## §2 决策表
| # | 主题 | 决策 | 依据 |
|---|---|---|---|
| **D-A** | 拆分路线 | **Route C:组合根嵌入五个子 service + handler 侧收窄接口**。`ContentService` 保留为**组合根**(5 个嵌入指针字段、自身零方法),五个子 service 各持自己需要的依赖;五个 handler 的 `svc` 字段类型改为**消费方定义的窄接口** | §1.5 spike H1–H4 实测;H2 使 19 测试点 + serve.go 五处装配零改动,H3 使 catalog.go 的 nil 占位消失。**facade 与窄接口两条路线的收益同时拿到,不是二选一** |
| **D-B** | 文件布局 | `content.go`(组合根 + 共享声明)+ `catalog.go` + `reaction.go` + `upload.go` + `submission.go` + `moderation.go`,共 **6 个文件**;命名与仓内惯例一致(`account.go`→`AccountService`、`bundle.go`→`BundleService`、`auth.go`→`AuthService`、`cleanup.go`→`CleanupService`) | §1.4 五条线 + §5c 归属映射 |
| **D-C** | 子 service 构造签名 | `NewCatalogService(store, objects)`、`NewReactionService(store)`、`NewUploadService(store, users, objects, bundle)`、`NewSubmissionService(store, objects)`、`NewModerationService(store, users, objects)`。**每个只收它实测用到的字段**(§1.4),不多收 | §1.4 依赖映射;先例 `NewBundleService` 只收两个子仓储 |
| **D-D** | 私有 helper 归属 | `randomToken` → `upload.go`;`versionFromUpload` + `pendingKeys` → `moderation.go`;`ParseSubmissionEnvelope` + `SubmissionEnvelope` → **留 `content.go`(SHARED,且 handler 依赖须保持导出)**;`ErrUnsupportedMediaType` + `allowedCoverTypes` → `upload.go`(**独占**,实测仅 256/258 使用);其余 11 个共享错误/类型 → 留 `content.go` | §5c 归属映射(含我对两处误归属的纠正记录)。**RE-PIN 补记**(审查者 M-8 实测):`ErrSubmissionConflict` 除投稿/审核两线与 `handler/submissions.go` 外,还被**两线之外的 `AccountService`(`account.go:136`,账号注销的 DeletionReport 路径)**使用——把它降级为小写即 `account.go:136:29: undefined: ErrSubmissionConflict` 编译红。故其 SHARED 判定**不仅正确而且必要** |
| **D-E** | handler 窄接口 | 五个 handler 各自在**自己的文件内**声明消费方接口(`catalogPort`/`reactionPort`/`uploadPort`/`submissionPort`/`moderationPort`,小写=包内私有),只列它实测调用的方法;struct 字段与构造参数类型改为该接口 | §1.2 零交叉;先例 `cmd/catalog.go:79 catalogRenderer`。**接口定义在消费方**(Go 惯例),不放 service 包 |
| **D-F** | `Approve` 170 行 | **本批不拆**。`moderation.go` 255 行在可接受范围;`Approve` 的 7 阶段内部重构(① 加载+状态校验 606–619 ② owner 查取 622–628 ③ 上传解析 630–644 ④ 重复性检查 switch 646–659 ⑤ 对象 Copy 661–681 ⑥ `WithTx` 事务 switch 683–759 ⑦ pending key 清理 + slog 766–772)**登记挂账**,留下一批(代码优雅维度) | 一批一个关注点。混入会让 diff 与审查面翻倍;P13 经验证明小而聚焦的批次审查质量更高。7 阶段映射已取证固化,下批可直接用 |
| **D-G** | 行为不变契约 | 纯结构重构:**零行为变更**。验收 = ① 四门全绿(gofmt/vet/build/`go test ./...` 11 包)② **既有测试断言零修改**(只允许因构造函数/类型变化而改装配代码,不允许改任何 `assert`/`expect` 语义)③ 公开 API 语义不变(22 个方法签名逐字不变,10 个共享声明保持导出)④ HTTP 行为逐字节不变 | 重构的定义。既有 11 包测试就是本批的守卫 |
| **D-H** | 死字段 | 删除 `ContentService.now`(§1.7)。`NewContentService` 的 **4 参数签名不变** → 19 个测试构造点与 2 个生产构造点**零改动** | §1.7 实测零引用 |
| **D-I** | `cmd/catalog.go` 依赖面 | 改为直接构造 `service.NewCatalogService(store, objects)` 喂 `handler.NewGames(...)`,**删除 `nil` bundle 占位与不需要的 `users` 依赖**(依赖槽 4 → 2) | §1.5 H3;现状注释「bundle 不参与导出」正是 god object 代价的自白 |
| **D-J** | 架构守卫(RED 先行) | 新增 `internal/service/architecture_test.go`,用 **reflect 钉住拆分结构**:① `ContentService` 恰有 5 个字段、**全部匿名(嵌入)、全部指针**、类型名恰为五个子 service ② 每个子 service 的**导出方法名集合**逐字等于其职责线的预期集合 ③ `ContentService` **无名为 `now` 的字段** | 防「方法迁回组合根」「重新长出具体字段」「死字段复活」。P12/P13 先例:守卫任务提前为 TDD 第一步,RED 输出本身即「有牙」证明 |
> ⚠️ **RE-PIN(2026-10-03,任务级审查 F1 important)**:原 D-J 的**三条断言与其自述目的不匹配**——三项里的「防方法迁回组合根」**没有任何一条断言覆盖**(断言①只数字段、②只看子 service、③只查字段名)。实测(审查者 M-3b + 控制者独立复现):在组合根上新增方法后**三断言全 PASS、`go build`/`go vet` 绿、全量 11 包测试也全绿** → god object 可静默回潮,零机械信号。另实测 `go vet` **不报告**组合根方法对提升方法的遮蔽(M-9,`vet_exit=0`)。
>
> **D-J 增订为七类断言**(④–⑦ 为 re-pin 后补,已由 commit `e195be0` 交付):④ `ContentService` 的**指针**方法名集合恰为五线导出方法之并(22 个)——抓「组合根新增方法」⑤ **源码扫描**:包内非测试 `.go` 里不得存在任何以 `ContentService` 为接收者的方法声明——这是唯一能抓「遮蔽提升方法」的机械手段(遮蔽后反射方法名集合不变、`go vet` 沉默),也是唯一能抓「**未导出**的组合根方法」的手段(`NumMethod()` 只暴露导出名,故断言④看不见它们)。⚠️ **RE-PIN(全分支终审 FR-1,important)**:④–⑦ 原由 `e195be0` 交付,但⑤的正则当时把接收者名写成**必需**分组(`\s*\w+\s+`),而 Go 允许省略不用的接收者名——遮蔽体恰好常不需它(`func (*ContentService) CoverURL(...)`),故该形态下 build/vet/七断言全绿而遮蔽确实生效(终审实测:经组合根调用返被接管值、直调子 service 返原值)。已由 `e04694b` 改为可选分组 `(?:\w+\s+)?` 并双向验证(合法树 7/7 绿;匿名指针/匿名值/有名三形态均 A5 红;负控制不过度捕获 `ContentServiceFoo`)。⑥ 五个子 service 的**字段名集合**逐字等于其依赖面(§1.4)——把「无可变内部状态」这个 §0 赖以论证可拆分的不变量变成机器契约(原断言③只禁字面名 `now`,故 `clock`/`logger`/`cache` 任何名字都能静默通过:钉的是名字而非不变量)⑦ 实例化 `NewContentService(nil, nil, nil, nil)` 后断言五个嵌入指针**均非 nil**——原断言①是纯静态类型检查,故构造器漏装某条线时 build/vet/守卫全绿,故障以运行时 nil 解引用 panic 出现(M-7)。
>
> **两条被 Go 提升规则 spike 实测否决的修法**(勿再尝试,理由已写入 `architecture_test.go` 注释):(i) 「断言 `reflect.TypeOf(ContentService{}).NumMethod() == 0`」**不成立**——嵌入的是 `*T`,其方法**同时进入值类型与指针类型的方法集**,故合法树上值类型 NumMethod 已是 22,该断言**恒假**(P13 恒真断言的镜像形态)。(ii) 「比 `Method(i).Func` 的同一性以侦测遮蔽」**不成立**——提升方法本身是 forwarding wrapper,合法树上 `root.CoverURL.Func`(5153408) 与 `CatalogService.CoverURL.Func`(5148192) **已不同**,遮蔽后为 5148288,两侧都 false → **无法区分遮蔽与否**。
| **D-K** | 派发形态 | **一个实现者子代理**做完 T1→T3。所有任务同动 `crearte-server` 单一工作树与 git index | AGENTS.md / `sdd-parallel-dispatch` §1「一仓一写者」;P12/P13 同款 |
### §2.1 被否方案
| 方案 | 否决理由 |
|---|---|
| **A:只拆文件、不拆类型**(`ContentService` 保持一个大 struct,方法分散到 6 个文件) | 消除不了 god object:每个 handler 仍声明对 22 方法的依赖,`catalog.go` 仍需 `nil` 占位。只解决「文件太长」这一个症状,不解决架构问题 |
| **B:删除 `ContentService`,五个 handler 各收子 service** | 真消除 god object,但 **19 个测试构造点 + 2 个生产构造点全部要改**,diff 与审查面显著变大,而 Route C 用嵌入拿到同样的架构收益且构造点零改动(spike H2/H4 实测)。B 可作为 Route C 之后的**渐进收尾**(若将来组合根不再被任何调用方需要) |
| **C:把五条线拆成五个独立包** | 过度。它们共享 `ContentStore` 与 11 个共享错误/类型,跨包会迫使这些声明全部导出并制造包间依赖;仓内惯例是 service 包内多文件多 struct(`account.go`/`bundle.go`/`auth.go`/`cleanup.go` 全在 `internal/service`) |
| **D:本批一并重构 `Approve` 170 行** | 见 D-F。两个关注点混在一批会让「行为不变」的验收判断变难 |
---
## §3 实现要求(逐条可核)
### 3.1 `internal/service/content.go`(拆分后 = 组合根 + 共享声明)
保留:`ErrContentNotFound`;`ContentService` struct(改为 5 个嵌入指针);`NewContentService`(4 参数签名不变,内部改为组装五个子 service,**不再赋 `now`**);11 个共享声明(`ErrSubmissionConflict`、`ErrWorkIDTaken`、`ErrVersionExists`、`ErrUploadUnavailable`、`ErrForbidden`、`submissionKinds`、`SubmissionEnvelope`、`ParseSubmissionEnvelope`、`SubmissionInput`、`SubmissionUpdate`,以及 `ErrUnsupportedMediaType` 迁走后其余保持原位)。
```go
// 组合根:五个职责线的嵌入组合。自身不声明任何方法——全部由嵌入提升。
// 保留它的唯一理由是让 19 个测试构造点与 serve.go 的装配零改动(spec §1.5 H2);
// 若将来无调用方需要「全量服务」,可整块删除(方案 B)。
type ContentService struct {
*CatalogService
*ReactionService
*UploadService
*SubmissionService
*ModerationService
}
func NewContentService(store repository.ContentStore, users repository.UserRepository, objects storage.ObjectStorage, bundle *BundleService) *ContentService {
return &ContentService{
CatalogService: NewCatalogService(store, objects),
ReactionService: NewReactionService(store),
UploadService: NewUploadService(store, users, objects, bundle),
SubmissionService: NewSubmissionService(store, objects),
ModerationService: NewModerationService(store, users, objects),
}
}
```
> ⚠️ **`now` 字段必须删除**(D-H)。删除后若 `time` 包在 content.go 内不再被使用,**必须同步删掉 import**(否则 `go build` 报 imported and not used)。实测 `content.go` 内 `time` 仅出现在 `now func() time.Time` 与 `now: time.Now` 两处 —— 实现者须自行 `grep -n 'time\.' internal/service/content.go` 确认后处置,**不要凭本句推断**。
### 3.2 五个子 service 文件
每个文件的形态(以 `reaction.go` 为例,最小依赖线):
```go
package service
// ReactionService = 反应职责线(收藏 / 评分)。只依赖 store(spec §1.4 实测)。
type ReactionService struct {
store repository.ContentStore
}
func NewReactionService(store repository.ContentStore) *ReactionService {
return &ReactionService{store: store}
}
// reactionTarget 是私有 helper,仅本线内使用(spec §1.1:3 处调用全在反应线)
func (s *ReactionService) reactionTarget(...) error { ... }
func (s *ReactionService) SetFavorite(...) (...) { ... }
// RateWork / UnrateWork / UserReaction / MyReactions
```
**机械迁移规则**(逐条可核):
1. 方法接收者 `s *ContentService` → `s *<Line>Service`;**方法体逐字不动**(字段访问 `s.store`/`s.users`/`s.objects`/`s.bundle` 在子 service 上仍然有效,因为各子 service 有自己的同名字段)。
2. 私有 helper 随其唯一使用线迁移(`reactionTarget`→reaction、`checkSubmissionRules`/`checkUploadRefs`→submission、`randomToken`→upload、`versionFromUpload`/`pendingKeys`→moderation)。
3. `ErrUnsupportedMediaType` + `allowedCoverTypes` → `upload.go`(独占,§5c)。
4. 每个新文件的 import 块**只列该文件实际用到的包**(`goimports` 语义)。禁止照抄 content.go 的 16 个 import。
5. **不得改动任何方法签名、错误值文本、slog 字段、SQL/仓储调用顺序**。这是 D-G 行为不变契约的机械保证。
### 3.3 五个 handler 的窄接口(D-E)
每个 handler 文件内声明自己需要的接口,**方法签名逐字照抄 service 侧**(不得简化参数或返回值):
```go
// games.go
// catalogPort 是本 handler 实际消费的最小面(spec §1.2:4 个方法)。
// 定义在消费方(Go 惯例;仓内先例 cmd/catalog.go:79 catalogRenderer)。
type catalogPort interface {
ListPublished(ctx context.Context, ...) ([]model.WorkWithVersion, map[string]model.WorkReaction, string, error)
GetPublishedDetail(ctx context.Context, ...) (model.WorkWithVersion, model.WorkReaction, string, error)
CoverURL(coverKey string) string
BundleURL(v model.WorkVersion) string
}
type Games struct {
svc catalogPort
}
func NewGames(svc catalogPort) *Games {
return &Games{svc: svc}
}
```
> ⚠️ **`ListPublished` / `GetPublishedDetail` 的完整签名必须从 `content.go` 逐字复制**(含中间那个未命名 `string` 返回值 = ETag)。不要凭 spec 里的省略号推断。实现者须先 `grep -n 'func (s \*ContentService) ListPublished' -A2 internal/service/content.go` 取原文。
其余四个同理:`reactionPort`(5 方法)、`uploadPort`(2 方法)、`submissionPort`(5 方法)、`moderationPort`(6 方法)。`admin.go` 的 `bundle *service.BundleService` 参数**保持不变**(它不是 ContentService 的一部分)。`uploads.go` 的 `maxBundle, maxCover int64` 同样不变。
### 3.4 `cmd/serve.go` 与 `cmd/catalog.go`(D-I)
- `serve.go`:五处 `handler.NewXxx(contentSvc, ...)` **零改动**(spike H2:提升后的组合根满足各窄接口)。
- `catalog.go`:把
```go
contentSvc := service.NewContentService(
repository.NewPostgresContentStore(pool), repository.NewPostgresUserStore(pool), objects, nil)
count, err := exportCatalog(cmd.Context(), handler.NewGames(contentSvc), out, pretty)
```
改为直接构造 catalog 线(**删除 `nil` bundle 占位与 `NewPostgresUserStore(pool)`**):
```go
catalogSvc := service.NewCatalogService(repository.NewPostgresContentStore(pool), objects)
count, err := exportCatalog(cmd.Context(), handler.NewGames(catalogSvc), out, pretty)
```
并同步更新那条「bundle 不参与导出」的注释——**依赖面已从 4 槽降到 2 槽,`nil` 占位不再存在**。若 `repository.NewPostgresUserStore` 在该文件内因此不再被使用,须检查 import / 变量是否残留。
---
## §4 边界(本批**不做**)
- **不改任何行为**:不动校验规则、错误语义、状态机、事务边界、slog 字段、HTTP 状态码映射。
- **不重构 `Approve` 内部**(D-F,登记挂账)。
- **不动 `AccountService` / `AuthService` / `BundleService` / `CleanupService`**(它们不持有 `*ContentService`,§1.6)。
- **不动 `memory_content.go`**(814 行,但实测是**测试替身**:除自身外零个非测试文件引用 `NewMemoryContentStore`,只 6 个 `_test.go` 用;属测试基建规模问题,非服务架构问题)。
- **不做 handler 错误映射去重**(`WriteError` 92 处,但 `errors.Is(err, service.ErrForbidden)` 仅 2 处 → 重复度不足以证明收益,候选 C,低于本批)。
- **不引入新依赖、不改 `go.mod`**。
- **不改任何测试的断言语义**(D-G)。若某个测试因构造函数签名变化而无法编译,**这本身说明拆分做错了**(D-A 的设计目标就是构造点零改动)。
---
## §5 测试计划
### T1 —— 架构守卫(**RED 先行**):`internal/service/architecture_test.go`(新增)
用 reflect 钉住拆分结构(D-J)。**七类断言**(①–③ 为原设计、④–⑦ 为 RE-PIN 后补,见 D-J 的增订说明):
1. **组合根形态**:`reflect.TypeOf(service.ContentService{})` 恰有 **5 个字段**,每个 `Anonymous == true`(嵌入)、`Kind == reflect.Ptr`,且 `Elem().Name()` 的集合恰为 `{CatalogService, ReactionService, UploadService, SubmissionService, ModerationService}`。
2. **各线导出方法名集合**(逐字,防方法迁移):
- `CatalogService` = `{ListPublished, GetPublishedDetail, CoverURL, BundleURL}`
- `ReactionService` = `{SetFavorite, RateWork, UnrateWork, UserReaction, MyReactions}`
- `UploadService` = `{CreateBundleUpload, CreateCoverUpload}`
- `SubmissionService` = `{CreateSubmission, ListMine, GetSubmission, UpdateSubmission, DeleteSubmission}`
- `ModerationService` = `{ListQueue, Approve, Reject, Unpublish, Republish, UpdateWorkFeatures}`
3. **死字段不复活**:`ContentService` 与五个子 service **都不得有名为 `now` 的字段**(D-H)。
4. **组合根方法集恰为提升之并**:`reflect.TypeOf(&ContentService{})` 的方法名集合逐字等于断言 2 五个集合的并(22 个)。**必须用指针类型**(子 service 方法全是指针接收者)。抓「组合根新增方法」。
5. **源码不得声明组合根方法**:扫描包内非测试 `.go`,任何匹配 `^func\s+\(\s*(?:\w+\s+)?\*?ContentService\s*\)` 的行都是违规。**接收者名必须是可选分组**(全分支终审 FR-1,important,已由 commit `e04694b` 修正):Go 允许省略不使用的接收者名,而遮蔽体恰好常常不需要它(`func (*ContentService) CoverURL(k string) string { return "X" }`);旧写法把名字当必需,故该形态下 build/vet/七条断言全绿而遮蔽**确实生效**。本扫描是**词法级**的:块注释内部以 `func (…ContentService)` 开头的行也会命中(FR-2 实测假红)——这是**刻意的过严**,不要为此放宽正则;若真被规约注释误伤,改法是剥掉 `/* */` 与 `//` 后再扫,而不是删掉这条断言。仓内有先例:`bundle/vector_test.go`、`cmd/catalog_test.go` 都用 `os.ReadFile`。
- ⚠️ **范围限定(终审 FR-1 推翻了我原先的全称声明)**:原文曾写「这是**唯一**能抓『遮蔽提升方法』的手段」。在 FR-1 修正**之前**这句为假(匿名接收者形态连它也抓不到)。修正后它在**导出与未导出方法的全部接收者形态**上成立,且范围比原文更宽:因 `reflect.Type.NumMethod()` **只暴露导出方法**,断言 4 的视野仅 22 个导出名,故**未导出的组合根方法也只有本断言能抓**(四轮 mutation 仅变方法名大小写即实测坐实:未导出两轮 A4 绿/A5 红,导出两轮 A4 红/A5 红)。
6. **各线字段名集合**:五个子 service 的字段名逐字等于 §1.4 的依赖面——`CatalogService={objects,store}`、`ReactionService={store}`、`UploadService={bundle,objects,store,users}`、`SubmissionService={objects,store}`、`ModerationService={objects,store,users}`。抓「重新长出内部状态」与「依赖面被悄悄放宽」。
7. **构造器不漏装**:`NewContentService(nil, nil, nil, nil)` 后五个嵌入指针均非 nil。传 `nil` 依赖是安全的:构造器只做纯组装、不解引用参数(既有测试已有多处以 `nil` bundle 构造,已证安全)。
**RED 相位要求**:T1 在 T2 之前提交时,`go test ./internal/service/` 必须以**编译错误**失败(`undefined: CatalogService` 等)。实现者须把该输出**逐字存证**到 `crearte-server/.superpowers/sdd-p14/impl-evidence/t1-red.txt`。Go 的 RED 不如 TS 那样能给出断言级消息,故**编译错误本身就是 RED 证据**——但必须存档,不能只在报告里描述。
**mutation 自查(实现者必做并附输出)**:守卫写完后,逐个验证它有牙:
- (a) 把某个方法(如 `CoverURL`)从 `catalog.go` 移回 `content.go` 并改接收者为 `*ContentService` → 断言 2 必须红;
- (b) 给 `ContentService` 加一个具体字段(如 `store repository.ContentStore`)→ 断言 1 必须红;
- (c) 给任一子 service 加回 `now func() time.Time` → 断言 3 必须红。
- (d) 在组合根**新增**一个方法(不移除任何子 service 方法)→ 断言 4 **与** 断言 5 必须红;
- (e) 在组合根**遮蔽**一个提升方法(**保留**子 service 原件)→ 断言 5 必须红、**断言 4 必须保持绿**(这正是断言 5 不可省的证明);
- (f) 给任一子 service 加一个**非 `now`** 的字段(如 `clock func() int64`)→ 断言 6 必须红、断言 3 必须保持绿;
- (g) 给某条线**放宽依赖面**(如给 `CatalogService` 塞 `users`)→ 断言 6 必须红;
- (h) 从 `NewContentService` 删掉某条线的组装行 → 断言 7 必须红。
每次 mutation 后 `git checkout -- <file>` 恢复并核 `git diff` 空。
> ⚠️ **RE-PIN(审查者 §12-1):mutation (a) 的原设计有一条绕行路径。** (a) 之所以会红,是因为它同时把方法从 `CatalogService` **拿走**了(断言 2 的集合缺员),**不是因为断言侦测到组合根上多出方法**。若把它改成「**复制**一份到组合根而保留 `catalog.go` 原件」(即 (e) 的遮蔽形态),(a) 的设计就会**误绿**——这是我 spec 里 positive control 自身的一个未被发现的盲区,由 (d)(e) 补上。
>
> ⚠️ **RE-PIN(控制者自伤教训):mutation 验证脚本的 `restore()` 绝不能用 `git checkout -- .`。** 若 fix-forward 编辑**尚未提交**,全量回滚会把它一起抹掉,导致后续各轮**全在测一棵没有修复的树**(本会话实测:第一轮后四条新断言被抹掉、`architecture_test.go` 回到 118 行,后四轮报「绿=3」= 原始三断言数,汇总显示四条「未按预期」)。修法:`restore()` 只回滚 mutation **触及的那几个源文件**,且每轮恢复后断言守卫文件的 `func Test` 计数仍为预期值,否则 **FATAL 终止**——让脚本在被自毁时大声失败,而不是静默产出「未按预期」的假结论。
>
> ⚠️ **遮蔽型 mutation 的签名必须逐字取原文。** 控制者第一版 (e) 用正则 `^func \(s \*CatalogService\) (Approve\(.*?\))` 抽签名,非贪婪 `.*?` 把返回值 `(error)` 截断了 → 生成的遮蔽体 `return nil` 与空返回值冲突 → **编译红而非断言红**,脚本却把它报成「守卫拦住了」。mutation 没编译过 ≠ 守卫有牙。
### T2 —— 提取五个子 service(T1 由红转绿)
按 §3.1–§3.2 执行。**完成后 `go test ./...` 必须 11 包全绿且既有测试文件零修改**(`git diff --stat` 里不应出现任何 `*_test.go`,除 T1 新增的那个)。
### T3 —— 收窄五个 handler + 消除 catalog.go 的 nil 占位
按 §3.3–§3.4 执行。**验收硬指标**:
- `grep -rn 'service.ContentService' internal/handler/` → **零命中**(五个 handler 全部改用窄接口);
- `cmd/catalog.go` 内 **`nil` 占位消失**、`NewPostgresUserStore` 不再被该构造使用;
- `cmd/serve.go` **零改动**(spike H2 的可核推论;若被迫改动,说明窄接口方法集抄漏了);
- 既有 handler 测试(`admin_test.go`/`games_test.go`/`reactions_test.go`/`uploads_test.go`/`integration_test.go` 等)**断言零修改**。
### 四门验收(dockerized Go,AGENTS.md 红线)
```bash
docker run --rm -v "$PWD/src:/src" -w /src \
-v crearte_gomod:/go/pkg/mod -e GOCACHE=/gocache -v crearte_gocache:/gocache \
-e GOPROXY=https://goproxy.cn,direct -e GOSUMDB=sum.golang.google.cn \
golang:1.24-alpine sh -c 'gofmt -l . && go vet ./... && go build ./... && go test ./...'
```
基线(P14 起点,已实测):`gofmt -l .` 空 · `go vet` 净 · `go build` exit 0 · `go test ./...` **11 包 ok**(api 包最慢 ~11.5s,含真库集成)。**host Go 1.18 不可用**;`proxy.golang.org` 从本机不可达,缺 `-e GOPROXY=https://goproxy.cn,direct` 会让下载挂死并杀死代理。冷构建 1–3 min → 用 background + 耐心 poll。
### 结构指标(交付时须报实测数字)
> **RE-PIN(审查者 F5):行数口径统一为 `wc -l`**(POSIX:数换行符),与 §0 的基线 856、`auth.go` 234 同口径——已实测核对:`git show dbf7fe5:src/internal/service/content.go | wc -l` = **856**、`wc -l auth.go` = **234**。**不要用 `len(text.split('\n'))`**:文件以换行结尾时它会多算一个空段(本批实测 content.go `wc -l`=82 而 split=83,六文件最大 `wc -l`=298 而 split=299,控制者据此报错过一轮数字)。本批两种口径都远低于目标,无实际影响;但若将来某文件恰落在边界(如 299 vs 300),口径歧义会直接决定 pass/fail。
| 指标 | 基线 | 目标 | 实测(`wc -l` 口径) |
|---|---|---|---|
| `content.go` 行数 | 856 | **< 120**(组合根 + 共享声明) | **82** ✅ |
| 六个文件最大行数 | 856 | **< 300**(submission.go 预计 ~290) | **298**(`moderation.go`,非 spec 预估的 submission.go)✅ |
| `service/` 目录内 >400 行的生产文件 | 1(content.go) | **0** | **0** ✅ |
| handler 里 `service.ContentService` 引用 | 10 处 | **0** | **0** ✅ |
| `cmd/catalog.go` 的 `nil` 占位 | 1 | **0** | **0** ✅(`NewPostgresUserStore` 亦归零) |
| `ContentService` 自身声明的方法数 | 25 | **0**(全部提升) | **0** ✅ |
| 既有 `*_test.go` 修改文件数 | — | **0** | **0** ✅(仅新增 `architecture_test.go`) |
六文件实测行数(`wc -l`):`content.go` 82 · `catalog.go` 110 · `reaction.go` 102 · `upload.go` 99 · `submission.go` 291 · `moderation.go` 298。
---
## §6 记账
- `crearte-server/docs/CHANGELOG.md` 新增 **`## [0.18.0] - 2026-10-03`**(插 `## [0.17.2]` 前)。格式:**同条目英文行紧跟中文行、无空行**;不同条目**空一行**;小节标题双语(`### Changed / 变更`、`### Tests / 测试`、`### Context / 背景`)。
- wrapper `docs/CHANGELOG.md` 新增 **`## [0.3.7] - 2026-10-03`**(插 `## [0.3.6]` 前),小节 `### Done / 完成`。
- `docs/ROADMAP.md`:新增 P14 行(P13 行之后)+ 文档索引表新增 P14 行(P13 索引行之后)。**哈希引用 merge commit**(P12/P13 惯例:ROADMAP 引合并提交、记账 commit 另列)。
- **`go.mod` / 任何 version 文件不 bump**(Go 服务无 package.json 类版本文件;仓内版本约定只体现在 CHANGELOG)。
- 挂账新增:`Approve` 170 行的 7 阶段内部重构(D-F)、`memory_content.go` 814 行测试替身规模、handler 错误映射去重(候选 C)、`AccountService`/`CleanupService` 的 `now` 字段使用情况复核(本批只确证 `ContentService.now` 死,未审其它)。
- **纪律**:显式 `git add <file>`,**禁 `git add -A`**。wrapper 的 `IDENTITY.md`/`SOUL.md`/`USER.md` 三个未跟踪文件**非本批产物,绝不 stage**。
---
## §7 风险与缓解
| 风险 | 缓解 |
|---|---|
| **嵌入提升在某处不满足窄接口** → 编译失败 | `go build ./...` 即验证。玩具 spike 已证机制(H2/H4),真签名的验证交给编译器;若失败,说明 §3.3 的方法集抄漏,按编译错误补全而非放宽接口 |
| **方法迁移时漏改字段名或误改方法体** | D-G + §3.2 规则 5「方法体逐字不动」。审查者用 `git diff` 逐函数比对;`go test ./...` 11 包(含 api 层真库集成 11.5s)兜住行为 |
| **import 块照抄导致 `imported and not used`** | §3.2 规则 4:每个文件只列实际用到的包。`go build` 会强制暴露 |
| **`time` import 在 content.go 变悬空**(删 `now` 后) | §3.1 已点名,但**要求实现者自行 grep 确认**而非凭 spec 推断 |
| **测试被迫修改 = 设计失败的信号** | §4 末条 + T3 验收硬指标「既有 `*_test.go` 修改文件数 = 0」。若实现者发现必须改测试,**停下来报告**而不是改 |
| **架构守卫本身写成败 assertion** | T1 的 mutation 自查(a–h)必须附红/绿输出。P13 教训:`max(a,b) ≥ k` 形态恒真、positive control 只钉一个子树 → **守卫自身要受同等审视**。**P14 实证了这条风险的两种形态**:(i) 恒真(P13)与恒假(本批被否决的 `NumMethod()==0` 修法)都要防;(ii) **守卫覆盖不全**比恒真更隐蔽——本批三条断言各自都真有牙(M-1b/M-2/M-5 双向验证实证),但合起来漏掉了「组合根长方法」整个维度,且 spec 自己的 positive control (a) 有绕行路径(见 §5-T1 的 RE-PIN) |
| **RE-PIN:组合根上直接访问依赖会编译失败(Route C 的额外性质,非缺陷)** | 组合根 `ContentService` **零具名字段**,故 `s.store` / `s.users` / `s.objects` / `s.bundle` 在组合根上都是 **ambiguous selector**(`catalog`/`upload`/`submission`/`moderation` 四个子 service 都有 `objects`,Go 提升规则下深度相同即歧义)。要访问须限定为 `s.CatalogService.objects` 这种形式。**这是比架构守卫更早的一道防线**:往组合根直接摸依赖的新代码编译期即失败。反过来也解释了为何 mutation (b) 加**具体字段**后守卫是唯一防线——加具体字段本身在 Go 里可编译(不与嵌入冲突) |
| **RE-PIN:`go vet` 不报告组合根方法对提升方法的遮蔽** | 审查者 M-9 实测 `vet_exit=0`:在组合根上写一个与提升方法同名同签名的方法(Go 深度规则下 depth 0 胜出)会**静默接管**全部调用,lint 层零信号。故断言 5(源码扫描)不可省——反射方法名集合在遮蔽后不变、断言 4 会保持绿(已由 mutation (e) 实证)。**这两条同属「组合根一旦长出代码就静默出问题」的同一风险面**,也是 §2.1 方案 B(将来若删除组合根)的收尾条件之一:只要组合根存在,就必须有断言 4+5 守着它。⚠️ **RE-PIN(终审 FR-1):这句曾是全称声明而范围为假**——断言 5 原正则要求接收者**有名**,故匿名接收者形态(`func (*ContentService) M(...)`)连它也抓不到,而遮蔽确实生效。现已由 `e04694b` 改为可选分组,该全称声明在**全部接收者形态**上成立;另因 `NumMethod()` 只暴露导出名,断言 4 对**未导出**的组合根方法是盲的,那部分同样只能靠断言 5。**教训:「唯一手段」这类全称声明写下时就要枚举形态(有名/匿名、值/指针、导出/未导出),否则它会成为下一个审查者的反例靶子** |
---
## §8 实现纪律(P11–P13 累计教训,逐条来自真实事故)
1. **不推断,只实测。** 任何「应该是 / 大概 / 按惯例」都要跑一条命令确认。P13 控制者在勘查期栽两次(用运行时注入验证构建期烘焙、用 `APPLY.toString()`+`new Function` 序列化注入),复核期写错断言/grep/锚点 **13 次**。
2. **断言/grep 返回意外值时,先怀疑自己的模式,别先宣布缺陷。** P13 实例:核不得回退项时两条 grep 返 0 而实际零变更(漏了中间三个类名 / 类序不同);查产物死 utility 时手写反斜杠转义返 0 而它真在(应用 `grep -F`);做归属分析时把**声明行**算成使用行。
3. **修守卫必须两个方向都验**:对合法值不误红 **且** 在 mutation 下不误绿。P13 控制者第一次纠正 scrim 判据时只验了前者,交付了一个**数学恒真**的断言(`max(填充侧,边框侧) ≥ 3`,下界 √16.50=4.0621>3);同一毛病在 alpha 兜底分支重演(只对当前浏览器输出验,漏了它声称支持的 CSS Color 4 形态)。
4. **`max(a,b) ≥ k` 是恒真断言高发区**:当 a、b 互补时 max 有非平凡下界,阈值低于该下界即永不可能失败。
5. **全称声明需要全称范围的证据。** P13 的 spec 写「accent 是全仓唯一作背景的地方」为假——spike 0 的 grep 与新守卫都只覆盖 `app/`,而 `runtime/` 与之**同级**。本批同理:任何「全仓零引用」「唯一使用点」的断言,grep 范围必须覆盖 `cmd/` + `internal/` 全部子树,且**区分声明行与使用行**。
6. **链式命令里别放会因「无匹配」而退 1 的 grep**(`grep -c` 输出 0 即退 1、`grep -v` 无剩余即退 1),会静默中断 `&&` 链导致后续步骤(如 commit)未执行。P13 因此丢过一次 commit。
7. **相对路径 `cd` 在循环里会漂移。** P13 收口对账时 `for r in ...; do cd $r; ...; cd ..; done` 让后三轮跑进错误目录,输出一堆 `fatal` 并**误报一个仓 porcelain=15**。用绝对路径 + 每仓独立子 shell。
8. **一仓一写者**(`sdd-parallel-dispatch` §1):所有任务同动 `crearte-server` 单一工作树,故交**一个**子代理,不并行争用 `.git/index.lock`。
9. **登记挂账前先 grep 既有测试与文档**,否则会开出「补一条已存在的测试」这类伪工作(P13 实例:`router_test.go:62` 早已钉住某边界,而我在 plan 里建议「补反面钉桩」)。
10. **合并纪律**:inner repo 提交前必须 `git branch --show-current` 确认不在 master;合并用 `--no-ff`;推送**禁管道**(P13 遇过 push 管道假绿);对账用 `git ls-remote` 且**必须校验变量非空**(空变量会让 `[ "$L" = "$R" ]` 假 MATCH)。
@@ -0,0 +1,400 @@
# P15-A 设计:拆分 `Approve` 169 行七阶段(crearte-server)
- **日期**:2026-10-03
- **仓**:`crearte-server`(Go module 根在 `src/`)
- **分支**:`chore/p15a-approve-phases`(AGENTS.md:18 只允许 `{feat|fix|docs|chore}/`)
- **base**:`fe810dd`(P14 merge,master)
- **维度**:代码优雅(USER.md 六维度循环)
- **前置**:P14 已交付(`ContentService` 拆分 + 七条架构守卫)。**本批不得改动 `architecture_test.go`**,除非某条断言因新 helper 变红(见 §5-T1 的 mutation (g))。
---
## §0 基线(已实测,2026-10-03 12:50)
**四门**(dockerized `golang:1.24-alpine`,AGENTS.md 硬红线;host Go 1.18 不可用):**`test -z "$(gofmt -l .)"` exit 0**(⚠️ **不能用 `gofmt -l . ; echo $?`**:`gofmt -l` 的语义是「列出需要格式化的文件」,**列出时仍 exit 0**,只在解析失败时 exit 2 → 旧配方的 `gofmt_exit=0` 不是证据。审查者 F3 实测。同时须把 `gofmt -l .` 的**原始输出**一并存证以证明为空)· `go vet ./...` exit 0 · `go build ./...` exit 0 · `go test ./... -count=1` **11 包 ok**(`internal/api` ~11.0s、`internal/service` ~3.7s)。⚠️ **这两个耗时是 mock 路径**:未设 `TEST_DATABASE_URL` 时真库测试**静默 SKIP**(实测 `internal/api` 4 个、全仓 `--- SKIP` 行 26 条)。受该变量门控的 Test 函数**全仓共 11 个**:`cmd` 2(`TestRewrapRotatesVersionsInPostgres`、`TestUserSetRoleCommand`)· `api` 4(`TestFullPublishChainOnPostgres`、`TestHostedPublishChainOnPostgres`、`TestAccountDeletionChainOnPostgres`、`TestAdminConsoleEndpointsOnPostgres`)· `repository` 2(`TestPostgresReactionGated`、`TestUserUsernameBackfillMigration`)· `service` 3(`TestApproveConcurrentOnlyOneWins`、`TestApproveSameWorkIDConcurrent`、`TestApproveOptionalFieldsRoundTrip`)。**「11 包 ok」不等于真库路径被验过**:本批实现者、任务级审查者、fix-forward 与控制者的**所有**跑批都 SKIP 了它们(§1.6 点名的两个并发/TOCTOU 守卫正在其中),直到全分支终审另起 `postgres:16-alpine` 对 base 与 HEAD 各跑全量 `-p 1 -v` 才补上这一层:**base `fe810dd` 194 PASS / 0 FAIL vs HEAD `b15c2a8` 199 PASS / 0 FAIL**(+5 恰为本批新增守卫),状态多重集完全一致、5 个 DB 守卫两树均真跑 PASS —— 这是「零行为变更」在 postgres 生产路径上的第一份运行时证明(此前所有证据都是静态的:T3 语句级比对、needle/标识符计数、mock 路径测试)。**合并前验证清单须含 DB-enabled 对照,否则下一批仍会静默 SKIP。**
**目标函数**:`internal/service/moderation.go:47-215` = **169 行**(`wc -l` 口径,P14 spec §5 已钉该口径)。
**`service/` 包内非测试文件的函数长度分布**(78 个函数,实测普查):
| 阈值 | 超过的函数数 | 清单 |
|---|---|---|
| >100 行 | **1** | `*ModerationService.Approve`(169) |
| >80 行 | 2 | + `*AccountService.deleteAccountLocked`(83) |
| >70 行 | 4 | + `*SubmissionService.UpdateSubmission`(76)、`ValidateWorkFields`(75) |
| >60 行 | 7 | + `*ImportService.ImportDir`(70)、`*CleanupService.Run`(70)、`*SubmissionService.checkSubmissionRules`(66) |
→ **`Approve` 是包内唯一 >100 行的函数**,且比次大者(83)大一倍。这就是本批的选型依据:不是"文件太长",是**单个函数承担了七件事**。
**行数口径**:一律 `wc -l`。**不要用 `len(text.split('\n'))`**——文件以换行结尾时后者多算一个空段(P14 F5:控制者与任务级审查者为此报出 83/299 与 82/298 两套数字)。
---
## §1 取证(决定设计,全部实测)
### 1.1 七阶段边界(`moderation.go` 真实行号;P14 挂账里 base 树的 606–772 **已作废**)
| 阶段 | 行 | 内容 | 返回的错误 | 是否碰 `tx` |
|---|---|---|---|---|
| ① 加载 + 状态校验 + 解析 | 48–62 | `Submissions().GetByID` → `ErrNotFound` 映射 `ErrContentNotFound`;`sub.Status != Pending` → `ErrSubmissionConflict`;`ParseSubmissionEnvelope(sub.Payload)`;`p := env.Work` | `ErrContentNotFound` / `ErrSubmissionConflict` / envelope 解析错 | 否 |
| ② owner 查取 | 64–70 | **仅** `Kind == NewWork` 时 `s.users.GetByID(sub.SubmitterID)` | `service: resolve submitter: %w` | 否 |
| ③ 上传解析 | **72–86** | `BundleUploadID`/`CoverUploadID` 各 `Uploads().GetByID`,并校验 `up.ConsumedBy == sub.ID` | `ErrUploadUnavailable` | 否 |
| ④ **乐观预检** switch | **88–101** | `NewWork`:`Works().GetByID` 已存在 → `ErrWorkIDTaken`;`NewVersion`:`Versions().Get` 已存在 → `ErrVersionExists`;其它 kind 无 case | `ErrWorkIDTaken` / `ErrVersionExists` / **`service: approve precheck: %w`** | 否(用 `s.store`) |
| ⑤ 对象 Copy | 102–123 | `finalBundleKey = "bundles/"+sub.WorkID+"/"+p.Version+".bin"`;`finalCoverKey = "covers/"+sub.WorkID+"/"+coverUpload.SHA256+path.Ext(...)`;各 `s.objects.Copy` | `ErrUploadUnavailable`(`storage.ErrObjectNotFound`)/ `service: copy bundle\|cover: %w` | 否(用 `s.objects`) |
| ⑥ **`WithTx` 事务** | **125–201** | `GetByIDForUpdate` 锁行 → **重校** `Status == Pending` → **悲观重检** switch(三分支)→ 建 Work / Version / Touch / UpdateMetadata → `MarkReviewed` | 见 §1.2 | **是**(`tx repository.ContentRepository`) |
| ⑦ 事务错误映射 + pending 清理 + slog | **202–214** | 事务错误映射(`repository.ErrConflict` → `ErrSubmissionConflict`);`pendingKeys(bundleUpload, coverUpload)` 逐个 `s.objects.Delete`(**只 `slog.Error` 不返回**,best-effort);`slog.Info("approve", …)`;`return nil` | `ErrSubmissionConflict` / 裸 `err` | 否 |
**`WithTx` 签名**(`internal/repository/content.go:38`):`WithTx(ctx context.Context, fn func(ContentRepository) error) error`。
### 1.2 🔴 阶段④与阶段⑥的 switch **不同形,禁止合并**(本批最重要的约束)
两者都 `switch sub.Kind`、都查 `Works().GetByID` / `Versions().Get`、都可能返回 `ErrWorkIDTaken`/`ErrVersionExists`,故看上去可以抽成一个"重复性检查" helper 共用。**实测比对后确认不可合并**,四处实质差异:
| 差异 | 阶段④(88–101) | 阶段⑥(136–199) |
|---|---|---|
| **kind 覆盖** | 只有 `NewWork` / `NewVersion` 两个 case | 三个 case:`NewWork` / `NewVersion` / **`MetadataChange`** |
| **`NewVersion` 的额外校验** | 只查 `Versions().Get` 是否已存在 | **还查** `work.Runtime != model.RuntimeVirtual \|\| bundleUpload == nil` → `ErrSubmissionConflict`;且 `Versions().Create` 后**还调** `tx.Works().Touch` |
| **`NewWork` 的动作** | 只查冲突,不建实体 | `PayloadToWork(p)` + 设 `OwnerID`/`CoverKey` + `tx.Works().Create`(`ErrConflict` → `ErrWorkIDTaken`)+ 条件建 Version |
| **错误文案** | 非 `ErrNotFound` 的错误包成 **`service: approve precheck: %w`** | **裸返 `err`**,无包装 |
**语义差异**(不可合并的根本原因):④是**乐观快失败**——无锁、在**任何对象 Copy 之前**,失败时副作用为零;⑥是**悲观重检**——持 `SELECT … FOR UPDATE` 行锁、在对象**已 Copy 之后**,失败时须靠阶段⑦清理 pending 对象。合并成一个 helper 会让"检查"与"写入"的边界模糊,且**改变错误文案**(`service: approve precheck: …` 会消失或蔓延到事务内),属行为变更。
→ **裁定:④与⑥各自独立拆 helper,不共享代码。** 表面重复是**有意的双层校验**(TOCTOU 防护:预检减少无谓 Copy,重检保证正确性),不是可消除的冗余。spec 里写明这条,防实现者"顺手 DRY"。
### 1.3 helper 形态:**未导出方法与包级函数都不触架构守卫**(实测,推翻 P15 账本 §8.1 的说法)
账本原写"新 helper 应是包级 `func`,因为方法会改动 `expectedLineFields`/断言 2 的方法集并触发守卫红"。**这句是错的**,已用两轮 mutation 实测推翻:
| 探针 | 追加到 `moderation.go` 的声明 | `go build` | 七断言 |
|---|---|---|---|
| A | `func (s *ModerationService) approvePrecheckLocked(ctx context.Context) error { return nil }`(未导出**方法**) | exit 0 | **7 PASS / 0 FAIL** |
| B | `func approvePrecheck(store repository.ContentStore, sub model.Submission) error { return nil }`(**包级**函数) | exit 0 | **7 PASS / 0 FAIL** |
**机制**:断言 2 `TestLineExportedMethods` 用 `m.IsExported()` 过滤,断言 4 遍历的 `reflect.Type.NumMethod()` **本身只暴露导出方法** → 未导出方法根本不在两条断言的视野内。断言 6 只看**字段**,不看方法。
→ **形态选择依据是"是否需要接收者字段",不是守卫约束**:
- 需要 `s.store` / `s.objects` / `s.users` 的 → **未导出方法**(体例:`account.go:78` 的 `func (s *AccountService) deleteAccountLocked(ctx, user) (DeletionReport, error)`,注释「共享内核,调用方已确认账号存在且未注销」,两处调用)
- 只需 `tx` + 已备好的数据 → **包级函数**(体例:`moderation.go:283` `versionFromUpload(workID, version, objectKey, up)`、`:290` `pendingKeys(uploads ...*model.Upload)`)
⚠️ **不得给 `ModerationService` 加字段**(断言 6 会红:`expectedLineFields["ModerationService"] = {objects, store, users}`);**不得在 `ContentService` 上加任何方法**(断言 5 源码扫描会红)。
### 1.4 `ContentStore` 内嵌 `ContentRepository`(决定事务 helper 的签名)
```go
// internal/repository/content.go:28-39
type ContentRepository interface {
Works() WorkRepository
Versions() WorkVersionRepository
Submissions() SubmissionRepository
Uploads() UploadRepository
Reactions() ReactionRepository
}
type ContentStore interface {
ContentRepository
WithTx(ctx context.Context, fn func(ContentRepository) error) error
}
```
→ **`ContentStore` 是 `ContentRepository` 的超集**。故收 `repository.ContentRepository` 的 helper **既能接 `s.store`(阶段④)也能接 `tx`(阶段⑥)**。但**本批刻意不用这个能力去合并④⑥**(见 §1.2);它只用于让事务体 helper 的签名收 `tx`。
### 1.5 `ParseSubmissionEnvelope` 的返回类型(决定 plan 结构体字段类型)
```go
// internal/service/content.go:54-60
type SubmissionEnvelope struct {
Work WorkPayload `json:"work"`
BundleUploadID string `json:"bundle_upload_id,omitempty"`
CoverUploadID string `json:"cover_upload_id,omitempty"`
}
func ParseSubmissionEnvelope(raw []byte) (SubmissionEnvelope, error) // 值返回,非指针
```
`Approve` 内 `p := env.Work`(`WorkPayload` 值)。**plan 结构体应持有 `p WorkPayload` 值而非 `env` 指针**——与现有代码逐字一致,避免引入 nil 判定分支(那会是行为变更)。
### 1.6 既有测试覆盖:**充分,不需要先补 characterization 测试**
`Approve` 有 5 个直接调用点(`internal/service/content_test.go:91,205,268,284,359`)与 13 个相关测试函数:
| 测试 | 覆盖的阶段/分支 |
|---|---|
| `content_test.go:18 TestApproveConcurrentOnlyOneWins` | ⑥ 的行锁 + 重校状态(并发只有一个赢) |
| `content_test.go:126 TestApproveSameWorkIDConcurrent` | ④/⑥ 的 `ErrWorkIDTaken`(断言文本:`loser error = %v, want ErrWorkIDTaken or ErrSubmissionConflict`) |
| `content_test.go:237 TestApproveMetadataChangeFeaturesSemantics` | ⑥ 的 `MetadataChange` 分支 + **`Features` 三态语义**(用 `meta-absent` 与 `,"features":{}` 两个 payload 精确区分 nil=保留 / 空 map=清空) |
| `content_test.go:293 TestApproveOptionalFieldsRoundTrip` | ①② 的 envelope 解析与字段往返 |
| `content_hosted_test.go:76 TestMetadataChangeRuntimeImmutable` | ⑥ 的 Runtime 不可变约束 |
| `namespace_test.go:132 TestNewVersionMetadataChangeOwnership` | ⑥ 的 OwnerID 归属 |
| `api/admin_test.go:83,131,149,197,231`(5 个) | HTTP 层:发布、Forbidden+DoubleApprove、同 slug 异命名空间、NewVersion、MetadataChange |
| `api/integration_test.go:27 TestFullPublishChainOnPostgres`、`api/hosted_postgres_test.go:28 TestHostedPublishChainOnPostgres` | ⑤⑥ 的完整发布链(真库) |
→ **既有测试就是本批的行为守卫**(与 P14 D-G 同构)。验收硬指标:**既有 `*_test.go` 修改数 = 0**。若实现者发现必须改测试,**停下来报告**而不是改(P14 §4 末条)。
### 1.7 调用面:`Approve` 只有一个非测试调用点
`internal/handler/admin.go:62`:`h.svc.Approve(c.Request.Context(), CurrentUser(c).ID, c.Param("id"), body.Note)`(经 `moderationPort` 窄接口)→ **新 helper 无须导出**,且 `Approve` 的**签名与错误语义必须逐字不变**(`moderationPort` 由 P14 断言 2 钉住 6 个方法名,签名变更会打破 `serve.go` 的接口满足性 → 编译红)。
---
## §2 设计决策
| # | 决策点 | 裁定 | 依据 |
|---|---|---|---|
| **D-A** | 拆分粒度 | **按七阶段拆 5 个 helper**(①②③ 合为一个"输入装载"、④ 一个、⑤ 一个、⑥ 一个(内部再拆 3 个 kind 分支)、⑦ 一个),不逐行拆 | §1.1 的阶段边界是**语义边界**(副作用发生点),不是任意切点。①②③ 都只读、都产出 plan 的字段、失败时零副作用 → 合为一个 helper 不损失可读性;④⑤⑥⑦ 各自有独立副作用(预检/Copy/事务/清理)→ 必须分开 |
| **D-B** | plan 结构体 | 新增**包级私有 struct** `approvePlan`,字段:`sub model.Submission`、`p WorkPayload`、`owner model.User`、`bundleUpload *model.Upload`、`coverUpload *model.Upload`、`finalBundleKey string`、`finalCoverKey string` | 七阶段之间传递的就是这七样东西(实测自函数体)。用 struct 而非 7 个返回值:Go 的多返回值超过 4 个即难读,且 ⑤ 要往 plan 里写 `finalBundleKey`/`finalCoverKey` 供 ⑥⑦ 用 |
| **D-C** | helper 形态 | ①②③④⑤⑦ → **未导出方法**(需 `s.store`/`s.users`/`s.objects`);⑥ 事务体 → **包级函数** `applyApprovalInTx(ctx, tx repository.ContentRepository, submissionID string, plan approvePlan, adminID, note string) error`;⑥ 内三个 kind 分支 → **包级函数** | §1.3 实测两种形态都不触守卫;选择依据是"是否需要接收者字段"。事务体不碰 `s.*`(只用 `tx`)→ 包级函数更纯,且签名自证"事务内不得访问 store 外的东西"。体例分别同 `deleteAccountLocked` 与 `versionFromUpload` |
| **D-D** | ④与⑥**不合并** | 各自独立 helper,不共享代码 | §1.2:四处实质差异 + 乐观/悲观语义不同 + 错误文案不同。表面重复是**有意的 TOCTOU 双层校验** |
| **D-E** | 事务边界与 Copy 顺序 | **一行不动**:⑤ 的两次 `s.objects.Copy` 必须在 ⑥ `WithTx` **之外**(故 ⑦ 需要清理);⑥ 的全部写入必须在 `WithTx` **之内**;`MarkReviewed` 必须是事务内**最后一个**调用 | 改变顺序即行为变更:Copy 进事务会让长耗时 I/O 持锁;`MarkReviewed` 提前会让"实体建成但状态未改"的中间态可被并发观察到 |
| **D-F** | `Features` 三态语义 | `if p.Features == nil { work.Features = existing.Features }` **连注释一起搬**进 MetadataChange helper,**不得"简化"** | 该注释原文:「旧提交(features 采集上线前创建)的 payload 无 features 键:保留现值,避免审批通过时静默清空已发布作品的运行权限;显式 `{}` 才是清空」。三态(nil=保留 / 空 map=清空 / 有值=覆盖)由 `content_test.go:237` 精确钉住,简化即红 |
| **D-G** | 零行为变更 | 纯结构重构:**方法体逐字迁移**,只改接收者/参数传递与缩进。`Approve` 的签名、返回的每个错误值与错误文案、`slog` 的两条日志(键名与顺序)**全部逐字不变** | P14 同款契约。验收:既有测试零修改 + 四门绿 + 字节级方法体比对 |
| **D-H** | 结构指标钉什么 | **钉「最大单函数行数」,不钉文件行数** | helper 仍在 `moderation.go`(298 行)内,拆完文件**可能不降反升**。用文件行数当指标会逼实现者把 helper 塞进新文件,那是无意义的文件增殖 |
| **D-I** | 派发形态 | **一个实现者子代理**做完 T1→T3(同动 crearte-server 单一工作树与 git index) | AGENTS.md / `sdd-parallel-dispatch` §1「一仓一写者」;P12/P13/P14 同款。**P15-B 在 crearte 仓,与本批可并行**(两仓各自单写者) |
### §2.1 被否方案
| 方案 | 否决理由 |
|---|---|
| **A:把④⑥的 switch 抽成共享 helper(DRY)** | §1.2 实测四处差异 + 乐观/悲观语义不同。合并会改变错误文案(`service: approve precheck: %w` 消失或蔓延)与 `NewVersion` 分支的校验集 → **行为变更**,违反 D-G |
| **B:用 `repository.ContentRepository` 参数统一④⑥(机械上可行)** | §1.4 证明 `ContentStore` 内嵌 `ContentRepository`,故技术上能让一个 helper 同时接 `s.store` 与 `tx`。但"能"不等于"该":它把①的乐观预检与⑥的持锁重检伪装成同一件事,**读者无法从签名看出锁语义差别** → 比 A 更隐蔽的行为语义损失 |
| **C:把七阶段拆到七个新文件** | 文件增殖。`moderation.go` 298 行本身不超标(P14 目标 <300),拆文件不解决"单函数太长"这个真问题,且 D-H 已判定不用文件行数当指标 |
| **D:先补 characterization 测试再重构** | §1.6 实测既有 13 个测试函数已覆盖全部七阶段与三态语义(含并发、真库发布链)。补测试是**额外成本而非额外保障**,且会违反"既有 `*_test.go` 修改数 = 0"这个更硬的验收判据 |
| **E:本批一并拆 `deleteAccountLocked`(83) / `UpdateSubmission`(76)** | D-F「一批一个关注点」(P14 同款)。三个函数分属 account/submission/moderation 三条线,混在一批会让"行为不变"的验收判断面翻三倍。已挂账 |
| **F:把 `Approve` 改成状态机/策略模式** | 过度设计。七个阶段是**线性**的(无分支跳转、无回退),`switch sub.Kind` 已经是策略分派。引入状态机会把 169 行变成更多的行 + 一个新抽象层,且**改变错误的产生位置**(行为变更) |
---
## §3 实现要求
### 3.1 目标形态(骨架,**签名为示意,实现者须按 §1.1 的真实类型逐字对齐**)
```go
func (s *ModerationService) Approve(ctx context.Context, adminID, submissionID, note string) error {
plan, err := s.loadApprovePlan(ctx, submissionID) // ①②③
if err != nil {
return err
}
if err := s.precheckApprove(ctx, plan); err != nil { // ④(乐观、无锁、Copy 之前)
return err
}
if err := s.copyApprovalObjects(ctx, &plan); err != nil { // ⑤(写 plan.finalBundleKey/finalCoverKey)
return err
}
err = s.store.WithTx(ctx, func(tx repository.ContentRepository) error {
return applyApprovalInTx(ctx, tx, submissionID, plan, adminID, note) // ⑥
})
if err != nil {
if errors.Is(err, repository.ErrConflict) {
return ErrSubmissionConflict
}
return err
}
s.cleanupApprovalUploads(ctx, plan) // ⑦(best-effort,只 slog.Error)
slog.Info("approve", "submission", submissionID, "work", plan.sub.WorkID, "kind", plan.sub.Kind, "admin", adminID)
return nil
}
```
⚠️ **注意 `copyApprovalObjects` 必须能写回 plan**:`plan` 是值类型,故该方法签名须为 `(ctx context.Context, plan *approvePlan) error`(收指针),或返回新的 plan。**选前者**——与 ⑤ 原地修改 `finalBundleKey`/`finalCoverKey` 的现有语义一致,且避免"返回新 plan 但调用方忘了接"的静默丢值。
### 3.2 逐阶段要求
**①②③ `loadApprovePlan`**(未导出方法):
- 逐字搬 **48–86** 行。返回 `(approvePlan, error)`。
- 三处错误映射逐字保留:`errors.Is(err, repository.ErrNotFound)` → `ErrContentNotFound`;`sub.Status != model.SubmissionStatusPending` → `ErrSubmissionConflict`;`fmt.Errorf("service: load submission: %w", err)`;`fmt.Errorf("service: resolve submitter: %w", err)`;两处 `ErrUploadUnavailable`(含 `up.ConsumedBy != sub.ID` 判定)。
- ② 的 `if sub.Kind == model.SubmissionKindNewWork` 条件**必须保留**(其它 kind 不查 owner,`plan.owner` 为零值;⑥ 的 `NewWork` 分支才用 `owner.ID`)。
- ③ 的 `bundleUpload = &up` 取地址语义**必须保留**(`up` 是循环内局部变量,取其地址是本函数刻意的写法;改成存值会让 `plan.bundleUpload != nil` 判定失效)。
**④ `precheckApprove`**(未导出方法):
- 逐字搬 **88–101** 行。**保持用 `s.store`**(不是 `tx`)。
- 错误文案 `fmt.Errorf("service: approve precheck: %w", err)` **逐字保留**(两处)。
- **不得**增加 `MetadataChange` case(④ 现在没有,加了会改变 `MetadataChange` 提交的失败时机)。
**⑤ `copyApprovalObjects`**(未导出方法,收 `*approvePlan`):
- 逐字搬 102–123 行。key 拼接公式**逐字保留**(`"bundles/"+sub.WorkID+"/"+p.Version+".bin"`、`"covers/"+sub.WorkID+"/"+coverUpload.SHA256+ext`,`ext := path.Ext(coverUpload.ObjectKey)`)。
- `if bundleUpload != nil` / `if coverUpload != nil` 的**条件 Copy 语义保留**(无上传时 `finalXxxKey` 保持 `""`,⑥ 的 `MetadataChange` 分支靠 `if finalCoverKey != ""` 判定是否覆盖)。
- `storage.ErrObjectNotFound` → `ErrUploadUnavailable` 的映射逐字保留;`service: copy bundle|cover: %w` 两条文案逐字保留。
**⑥ `applyApprovalInTx`**(包级函数):
> ⚠️ **RE-PIN 2026-10-04(第二次,闭合全分支终审 N1–N4)**:本 spec 里凡可执行的技术细节(形参顺序、行号区间、needle 命中行)一律以**代码实体**为准 —— `applyApprovalInTx` 的形参序是 `ctx context.Context, tx repository.ContentRepository, submissionID string, plan approvePlan, adminID, note string`(`submissionID` 在 `plan` 之前);base 树 `fe810dd` 的阶段区间为 ①②③`48–86` / ④`88–101` / ⑥闭包体`126–200`(75 行)/ ⑥整体`125–201`(77 行)/ ⑥内 switch`136–199`;HEAD 上 CG2 needle 唯一命中 `:201`。首次 re-pin 时控制者凭记忆写了 4 处不一致(N1 形参序 3 处、N2/N3 区间 staleness、N4 行号与算术),全分支终审逐条实测抓出。**教训已入 §8 纪律 11:可执行细节必须从代码实体 grep 出来粘贴,不得手写。**
- 搬 **126–200** 行(`WithTx` 闭包体)。⚠️ **`:201` 是 `})`,`:202–207` 的事务错误映射属阶段⑦,都不得搬进 helper**(照抄旧区间「126–205」会把 `})` 与闭包外半截搬进去 → 编译红或行为变更)。签名收 `(ctx, tx repository.ContentRepository, submissionID string, plan approvePlan, adminID, note string) error`(**含 `submissionID`,见 F9 裁定**)。
- **`GetByIDForUpdate` 锁行 + 重校 `Status == Pending` 必须在最前**(125–134),且 `errors.Is(err, repository.ErrNotFound)` → `ErrSubmissionConflict`(**注意与①不同**:① 映射 `ErrContentNotFound`,⑥ 映射 `ErrSubmissionConflict`——这个差异是刻意的,事务内消失意味着并发删除,语义是冲突而非未找到)。
- 三个 kind 分支各自拆包级函数(`createWorkInTx` / `createVersionInTx` / `applyMetadataChangeInTx`),或保留为一个 switch——**由实现者按可读性定**,但 `switch` 的分支顺序与 `MarkReviewed` 在末尾的位置**不得变**。
- `MetadataChange` 分支的 `Features` 三态**连注释一起搬**(D-F)。
- 事务内错误**裸返**,不加 `service: approve precheck` 之类包装(§1.2)。
**⑦ `cleanupApprovalUploads`**(未导出方法):
- 搬 **208–212** 行(pending 循环)。⚠️ **`:213` 的 `slog.Info` 与 `:214` 的 `return nil` 留在 `Approve` 本体,不得搬走**(旧区间「212–213」会把该搬走的和该留下的正好搞反)。`for _, key := range pendingKeys(plan.bundleUpload, plan.coverUpload)` + `s.objects.Delete` + `if err != nil && !errors.Is(err, storage.ErrObjectNotFound) { slog.Error("approve: pending delete failed", "key", key, "error", err) }` **逐字保留**。
- **不得返回 error**(best-effort 语义:清理失败不能让已成功的审批变失败)。
- ⚠️ `slog.Info("approve", …)` 与 `return nil` **留在 `Approve` 本体**,不进 helper——helper 名是 `cleanup`,把成功日志塞进去会让职责不清。
### 3.3 不得触碰
- `architecture_test.go`(P14 的七条守卫)——除非 mutation (g) 证明某条因新 helper 变红,那时**停下来报告**,不要自行改守卫。
- `moderationPort`(`handler/admin.go`)与 `serve.go`。
- `versionFromUpload` / `pendingKeys` 的**签名**(可调用,不可改)。
- 任何 `*_test.go`。
- `docs/`、`go.mod`/`go.sum`(AGENTS.md 红线:实现者不得碰)。
---
## §4 边界
| 项 | 在范围内 | 不在范围内 |
|---|---|---|
| 函数 | `Approve` 及其拆出的 helper | `Reject`/`Unpublish`/`Republish`/`UpdateWorkFeatures`/`ListQueue`(同文件其它方法)、`deleteAccountLocked`(83)、`UpdateSubmission`(76) |
| 文件 | `internal/service/moderation.go` | 其它 service 文件(除非编译需要,须报告) |
| 行为 | 零变更(D-G) | 任何错误文案/日志键名/错误产生时机的调整 |
| 测试 | 无(既有测试即守卫) | 新增测试、修改既有测试 |
| 守卫 | 无 | 改 `architecture_test.go` |
**挂账**(本批发现但刻意不做):`deleteAccountLocked`(83 行,account 线)、`UpdateSubmission`(76)、`ValidateWorkFields`(75)、`ImportDir`(70)、`CleanupService.Run`(70)、`checkSubmissionRules`(66) —— 六个 >60 行函数,各自独立成批。
---
## §5 测试计划
### T1 —— 结构守卫(**RED 先行**):扩展或新增函数长度断言
P14 的七条断言钉的是**类型形态**,没有一条钉**函数长度**。本批的验收核心是"`Approve` 从 169 行降到 <60",若没有机械守卫,下一个贡献者可以把它加回去而无人拦截。
**新增 `internal/service/function_length_test.go`**(**新文件,不改 `architecture_test.go`**):
1. **`TestNoOversizedFunction`**:解析 `internal/service/` 下所有非测试 `.go`,统计每个 `func` 的行数(`^func ` 起,括号深度归零止),断言**没有任何函数 >100 行**。
- **阈值 100 的依据(实测,非拍脑袋)**:§0 普查显示当前包内 >100 行的函数**只有 `Approve`(169) 一个**,次大是 83 → 阈值 100 在**修前恰好红一条**(`Approve`)、**修后全绿**,且给次大者(83)留 17 行余量不至于误伤。
- ⚠️ **不得用 60 当阈值**:那会让 `deleteAccountLocked`(83)、`UpdateSubmission`(76)、`ValidateWorkFields`(75)、`ImportDir`(70)、`Run`(70)、`checkSubmissionRules`(66) 六个**本批范围外**的既有函数立刻报红,把"重构 `Approve`"变成"重构整个包"(违反 D-F/E)。
2. **`TestApproveIsSmall`**:断言 `Approve` 本身 **<60 行**(本批的直接目标;169 → <60)。
3. **`TestApproveHelpersExist`**:断言 §3.1 骨架里的 helper 名存在(`loadApprovePlan`/`precheckApprove`/`copyApprovalObjects`/`applyApprovalInTx`/`cleanupApprovalUploads`)——防实现者只把代码挪进一个匿名闭包了事。
- ⚠️ **这条断言的实现方式必须是源码文本匹配**(`os.ReadDir` + 正则),**不能用 reflect**:未导出函数在 reflect 里不可见(§1.3 同一机制)。
**RED 相位要求**:T1 在 T2 之前提交时,`go test ./internal/service/ -run 'TestNoOversizedFunction|TestApproveIsSmall|TestApproveHelpersExist'` 必须**红**,且红的原因是**断言失败而非编译错误**(`Approve` 169 行 > 100、helper 名不存在)。**这与 P14 T1 的 RED 形态不同**(P14 是 `undefined: CatalogService` 编译红)——本批 T1 引用的都是既有类型,故必须是断言红。实现者须把 RED 输出**逐字存证**到 `.superpowers/sdd-p15a/impl-evidence/t1-red.txt`。
**mutation 自查(实现者必做并附输出,每条测"预期红 + 其余绿 + 非编译红 + 恢复证明")**:
- (a) 把 `Approve` 的某阶段**内联回去**(helper 存在但 `Approve` 不调它,改为原地展开)→ `TestApproveIsSmall` 必须红
- (b) 给某个 helper 塞代码使 `Approve` 仍 <60 但**某函数 >100**(例如把⑥的三个 kind 分支全塞进 `applyApprovalInTx` 而不拆)→ `TestNoOversizedFunction` 必须红
> ⚠️ **(b) 的可行性须先实测**:若⑥整体(126–200 = **75 行**)搬进 `applyApprovalInTx` 而不再拆 → **不会红**;**更宽的实测盲区:仅合并④⑥(正是 §2.1 被否方案 A 的形态)= 91 行,仍不红**;真红下界实测 >100 行。这是**阈值 100 的已知盲区**,不是守卫失效:它钉的是"不再有 169 行的巨函数",不是"每个 helper 都短"。实现者须在报告里写明这一点,**不得声称 (b) 覆盖了所有塞代码形态**。若要在 (b) 上取得真红,须构造 >100 行的 helper(例如把 ④⑥ 两个 switch 都塞进一个 helper)。
- (c) 删掉一个 helper(把其内容合并进 `Approve`)→ `TestApproveHelpersExist` 必须红
- (d) 把 helper 名改成别的(如 `loadApproveInputs`)→ `TestApproveHelpersExist` 必须红(**这条是 (c) 的对照**:证明断言钉的是名字集合而非"存在任意 5 个函数")
- (e) 把 `TestNoOversizedFunction` 的扫描范围改成一个空目录 → 必须红(**防"扫 0 文件也报 0 违规"的假信心**,P13 审查者 M5 的同族教训;断言内须含"至少扫到 N 个 `.go` 文件"的前提检查)
- (f) 把阈值从 100 改成 1000 → 必须红(同 (e),防阈值被放宽;断言须自证阈值字面量)
- (g) **给 `ModerationService` 加字段**(如 `clock func() time.Time`)→ P14 的断言 6 `TestLineFields` 必须红(**跨批回归检查**:证明本批新增文件没有削弱 P14 守卫)
- (h) **在 `ContentService` 上加方法** → P14 的断言 5 必须红(同 (g),且**须同时测有名与匿名两种接收者形态**——P14 FR-1 的教训:只测有名会漏掉 `func (*ContentService) M()`)
- ⚠️ **(RE-PIN 2026-10-03)spec 的 (a)–(h) 对审查者发现的缺口全部无牙**,fix-forward 轮须复跑审查者自己的轮次:**RV-A / RV-B / RV-C / RV-J**(四条合并形态,修后应由 CG 守卫红)· **RV-I / RV-S**(修后应由正向存在性前提红)· **RV-E**(修后应给出合理错误而非 `read memory.go: no such file`)· **RV-O2**(修后应由 F2 新断言红)· **RV-P / RV-Q**(修后应由自证钉红)· **RV-R**(修后**仍应全绿** = 固有上限,**须在报告里明写「未能防住,靠 review 兜」,不得声称已修**)· **RV-N**(泛型形态修后应命中)· **RV-F / RV-G / RV-H**(跨批回归,应仍与实现者报告一致)
每次 mutation 后**只回滚触及的文件**(`git checkout -- <file>`,**绝不用 `git checkout -- .`**——P14 控制者第 24 次自伤:全量回滚会抹掉未提交的守卫编辑,导致后续各轮全在测没有修复的树),并核 `git diff` 空 + 守卫文件 `func Test` 计数不变。
### T1.4 —— ④⑥ 独立性**常驻**守卫(⚠️ RE-PIN 2026-10-03,审查者 F10 裁定采纳)
spec §7 风险表第 1 行原写「T3 字节级比对会暴露合并」——**但 T3 是一次性证据,报告归档后下一个贡献者合并 ④⑥ 时没有任何东西会红**。审查者实测确认这个状态不可接受(§1.2 把 ④⑥ 不合并列为「本批最重要的约束」,却零常驻机械信号)。
**新增 `internal/service/approve_phases_test.go`**(新文件;不动 `architecture_test.go`),三条子断言:
- **CG1**:`fmt.Errorf("service: approve precheck: %w"` 只能出现在 `precheckApprove` 的 span 内
- **CG2**:`.Submissions().GetByIDForUpdate(` 只能出现在 `applyApprovalInTx` 的 span 内
> ⚠️ needle **必须收紧成 `.Submissions().GetByIDForUpdate(`**:`moderation.go` 内唯一命中 `:201`;而 `account.go:46`、`auth.go:122` 是 **`users.GetByIDForUpdate`**(不同接收者)。不收紧则将来 `moderation.go` 新增别的 `GetByIDForUpdate` 调用会误红。
- **CG3**:两个 helper 体内各自含 `switch …Kind`,且**互不调用**
**关键设计**:语料**先过 `stripComments`(只掩注释、保留字符串字面量)**。`moderation.go:141` 与 `:191` 的注释里**合法地**提到 precheck 文案 → 不掩注释会假红;而字符串字面量必须保留,否则 `fmt.Errorf("...")` 这个 needle 根本匹配不到。span 复用 `function_length_test.go` 的 `scanFuncs`(共享同一分词器,也就共享 F2 的闭包盲区,但闭包形态与 ④⑥ 合并无关)。
**审查者已双向验证**:合法树 **绿**;**4 种合并/退化形态全红**且全部 `compile_red=False`(是断言红不是编译红),三条 shipped 守卫在这些形态下**全绿**:
| 轮 | 形态 | shipped 三条 | CG 守卫 |
|---|---|---|---|
| RV-A | **活的** ④⑥ 语义合并:两个 helper 都委托给同一个 `checkKindConflicts(ctx, store repository.ContentRepository, plan)`(= §2.1 被否方案 **B** 的实现;比实现者 (b-iii) 的静态拼接更强——它真的会跑) | 全绿 | **红**(2 条) |
| RV-B | 掏空 ④:`precheckApprove` → `return nil`(声明保留,名字守卫满足) | 全绿 | **红** |
| RV-C | 把 ⑥ 的 `GetByIDForUpdate` 搬进 ④(摧毁 TOCTOU 分层,**标识符计数完全不变**) | 全绿 | **红**(CG2 越界) |
| RV-J | 「最自然的 DRY」:④ 的两个 case 折进 ⑥ 的 switch、⑥ 持有 precheck 文案、④ 变空壳 | 全绿 | **红**(CG1 越界 + CG3) |
> ⚠️ **RV-C 同时推翻了控制者的核实方法**:把 `GetByIDForUpdate` 从 ⑥ 搬进 ④,标识符计数不变、错误文案不变,而 TOCTOU 分层被摧毁(乐观预检变成持锁预检,失败时机与锁语义全变)。**「计数一致」证明不了控制流没变**——必须配 **顺序** 与 **位置** 证明(T3 的 order-violation 检查 + 审查者的 per-destination 单调性检查)。
**⚠️ 已知边界(须原样写进守卫注释;纪律 #4:不写全称声明)**:若合并者把 precheck 文案**留在** `precheckApprove` 内、同时把 ⑥ 的逻辑**复制**进去(复制而非移动),CG1 不红——此时需要靠「两处 `switch …Kind` 的 case 集合不同」这类更强断言。**该形态未验证,故不声称 CG1–CG3 覆盖所有合并形态。**
### T2 —— 执行拆分(T1 由红转绿)
按 §3 执行。**完成后 `go test ./...` 必须 11 包全绿且既有测试文件零修改**。
### T3 —— 字节级方法体保真证明(加做项,P14 同款)
写脚本对 base 树(`fe810dd`)的 `Approve` 函数体与新树的 `Approve` + 5 个 helper 做**语句级比对**:base 的每一行(归一化缩进与接收者前缀后)必须在新树中出现,且**顺序保持**(阶段①→⑦ 的相对顺序不得变)。产出三张清单:`verbatim`(逐字一致)、`changed`(刻意变更,须逐条给理由)、`missing`(**必须为 0**)。
⚠️ **脚本的已知坑(P14 两位审查者都栽过)**:
- 提取声明块时**不能"剥行注释后数括号"**——字符串字面量里的 `//`(如 `strings.HasPrefix(k, "https://")`)会被当注释起点吃掉右括号。须用**状态机分词器**跟踪 `"` / `` ` `` / `'` / `//` / `/* */`,把字面量与注释内容替换为**等长空格**后再数括号(P14 任务级审查者 F6 的修法)。
- **单行 `var X = errors.New(...)` 须有提前终止分支**,否则会把后续声明并入同一块(P14 F6 的残留局限)。
- 若工具给出意外值(如某方法报 CHANGED),**先怀疑自己的模式**,不要据此指控实现者(P14 F6:第一版工具误报 `CoverURL` 为 CHANGED,若照它写报告会产生一条完全虚假的 critical finding)。
### T4 —— 审查裁定后的守卫加固(fix-forward 轮,⚠️ RE-PIN 2026-10-03)
任务级审查(`.superpowers/sdd-p15a/task-review-report.md`,18 轮自设 mutation)裁决 **PASS with notes**,控制者裁定合并前修 6 项(`.superpowers/sdd-p15a/adjudication.md` §二):
| # | finding | 改什么 | 审查者的实测证据 |
|---|---|---|---|
| **F1** 🔴 | `readNonTestSources` 的 `dir` 参数是**半成品**:`os.ReadDir(dir)` 但 `os.ReadFile(name)` → `dir` 只重定向「数哪些文件」,不重定向「读哪些内容」 | `os.ReadFile(filepath.Join(dir, name))` + import `path/filepath` | **RV-I**:base 树(`Approve` 169 行)+ `dir="zzscan"`(12 个同名文件、不含 `moderation.go`)→ 前提满足、`TestNoOversizedFunction` **PASS**,**而它唯一存在的理由(拦住 169 行 `Approve`)完全失效**。这恰好是守卫自己注释声称能防住的形态 |
| **F2** 🔴 | `scanFuncs` 只对 `^func` 开 span → **`var f = func(...) {…}` 的函数体永不被测量** | 新断言:service 包内 `^var .* = func(` 形态的**包级闭包数量 = 0**(现状 0) | **RV-O2**:真实可编译、gofmt 干净的包内文件含 **125 行闭包** → 四门全绿、三条守卫全 PASS(`scanned 16 files / 86 funcs`——文件被数进去但一个函数都没测出来)。**比实现者自曝的 (b) 盲区更宽:那是「≤100 行的合并形态」,这是「任意长度的非 `func` 形态」** |
| **F3** 🔴 | **四门的 gofmt 门是退出码盲的**:`gofmt -l .` 列出未格式化文件时**仍 exit 0**(只在解析失败时 exit 2) | 配方改 `test -z "$(gofmt -l .)"`,并同时存证原始输出为空 | 构造含缩进顶层声明的文件:`gofmt -l .` **列出** `main.go` 而 `gofmt_l_exit=0`。**控制者与实现者共用旧配方 → 同一盲点被复算两次而没被发现**(记为控制者错误 #32)。**与 F2 叠加后缩进逃逸路径完全敞开**:` func …` Go 编译器接受、`scanFuncs` 扫不到、三条守卫全绿,唯一防线就是这个不会红的 gofmt 门。**这是 P14「管道吞退出码」教训的镜像形态:命令本身不产生非零退出码,比管道吞码更隐蔽,因为 `$?` 看起来可信** |
| **F4** 🔴 | 三个守卫常量里**两个无自证钉**,有钉的那个**只防单边改** | (a) **必修**:前提检查从「文件数 ≥ N」改成**正向存在性断言** `if _, ok := srcs["moderation.go"]; !ok { t.Fatalf(...) }`(不依赖魔法数字,同时治 RV-I 与 RV-S 的根);(b) 给 `approveLineBudget`/`minScannedFiles` 补自证钉;(c) 注释写明「同步改值 + 同步改钉」是**固有上限**、只能靠 review 兜 | **RV-P**(`approveLineBudget` 60→**600**)全绿、日志 `Approve = 24 lines (< 600)`,**静默失去全部牙齿**;**RV-Q**(`minScannedFiles` 12→**0**)全绿,前提变 `len(srcs) < 0` **恒假 → 永不触发**;**RV-R**(**同步**改阈值 100→1000 **且**同步改自证钉)全绿 → **自证钉防不住阈值放宽**;**RV-S**(RV-Q + `dir="zzempty"` 0 个 .go)→ **PASS**、日志 `scanned 0 files / 0 funcs`,**精确复现守卫注释声称能防住的「扫 0 文件也报 0 违规的假信心」** |
| **F7** 🟡 | helper 正则要求名字后**紧跟 `(`** → **泛型声明形态** `HELPER[T any](` 不命中 | `HELPER\(` → `HELPER(?:\[[^\]]*\])?\(` | RV-N 实测三个 helper 均 `false`。**失败方向是红(过严)不是绿(绕过)**,故不是安全洞而是脆弱性;但与 F2 的缩进逃逸**同源**:都源于 `^func` / `HELPER\(` 这类**行首锚定的词法匹配** |
| **F9** 🟢 | `applyApprovalInTx` 签名不含 `submissionID`,实现者用 `plan.sub.ID` 替代(附跨两个 repository 实现的同值论证) | **加 `submissionID` 形参**,⑥内两处(`GetByIDForUpdate`、`MarkReviewed`)恢复逐字用 `submissionID` | 审查者独立复核**同值成立**、并发安全验证通过;但本批唯一验收判据是「零行为变更」,而该替换让判据**依赖一条跨实现论证** → 加形参后这两处逐字一致、**彻底消除依赖**,且 T3 的 changed 从 **15 降到 13**(证据更强) |
| **F6** 🟡 | 实现者报告 §6 的 changed 分类表**与它自己的证据对不上账**(stated `9+3+2+2 = 16`,证据标签合计 **15**) | 更正为 `多值返回 7 · 声明折叠 2 · 零值折叠 2 · 赋值形态 1 · =→:= 1 · 实参来源 2 = 15` | **实质无问题**(15 条逐条有理由、162 = 147+15+0 对账闭合、审查者独立 differ 精确复现归属),是**汇总表算术/誊写错误**非证据造假。但 USER.md 明写「报数字要报实跑输出」,且 P14 有过「控制者报出 83/299 与 82/298 两套数字」的先例 |
**DEFER(另开批 + 挂账)**:**F5** —— P14 断言 5 有一条未封死的绕过:**类型别名接收者**(`type X = ContentService` + `func (c *X) CoverURL(...)`)。审查者 RV-M 硬证据:**遮蔽确实生效**(组合根返回 `RV-M-SHADOW`),而**用与 `architecture_test.go` 逐字相同的正则**实测命中数 = **0**;RV-K:全部 **10 条守卫 + vet + build 全绿**。与 P14 FR-1(匿名接收者)**同族**:FR-1 的修法把接收者名改成可选分组,但仍要求 `ContentService` 这个字面 token 出现在 `(` 之后,别名形态恰好把这个 token 换掉了。→ `architecture_test.go:173` 的「**唯一**能侦测遮蔽提升方法的机械手段」这个全称声明**在别名形态下被推翻**(纪律 #4)。**pre-existing(P14 遗留),不算本批扣分项**;且修它必须动 `architecture_test.go`(本批禁触)→ 另开批。最小修法:禁止包内出现指向 `ContentService` 的类型别名,一条正则 `^type\s+\w+\s*=\s*\*?ContentService\b`(合法树 0 命中、RV-K/RV-M 红);更强修法:改用 `go/ast` 判定接收者类型是否 resolve 到 `ContentService`(含别名),**一次性解决 F2/F5/F7 三条的词法根因**。
**偏离 1 的裁定(审查者 §10)**:`5fd12e5` 给 `readNonTestSources` 加 `dir` 参数——**动机成立**(spec §5 要求每条 mutation 测「预期红 + 其余绿」,共享扫描入口无法隔离)、**「RED 输出前后逐字同款」验证成立**(两份存证失败内容完全一致,只有行号整体 +3)、**但实现有 bug(F1)且换来的收益是虚的**(审查者:「为了演示而给生产代码加参数、且加出 bug,是净损失」)。审查者倾向回退;**控制者裁定保留参数化并按 F1+F4(a) 修**——理由:(e) 的隔离演示有独立价值(它是「预期红 + **其余绿**」这个硬要求的唯一实现路径),F1 是一行修,而 F4(a) 的正向存在性断言比回退更能治根。
### 四门验收
```bash
cd crearte-server && docker run --rm -v "$PWD/src:/src" -w /src \
-v crearte_gomod:/go/pkg/mod -e GOCACHE=/gocache -v crearte_gocache:/gocache \
-e GOPROXY=https://goproxy.cn,direct -e GOSUMDB=sum.golang.google.cn \
golang:1.24-alpine sh -c 'gofmt -l . ; test -z "$(gofmt -l .)" ; echo gofmt_gate=$? ; go vet ./... ; go build ./... ; go test ./... -count=1'
```
⚠️ **`-count=1` 强制实跑**(禁缓存)。⚠️ **落盘证据用 `setsid nohup` 脱离 + 宿主侧重定向**(容器只挂 `/src` 写不了 `.superpowers/`;exec session 一断容器就被杀——P14 实现者与任务级审查者都踩过)。⚠️ **`grep -c` 输出 0 时 exit 1**,放 `&&` 链里会静默中断后续步骤(含 `git commit`)。⚠️ **`pkill -f <pattern>` 会匹配到自己的命令行**(P14 控制者把自己 SIGKILL)→ 用 `[p]attern`。
### 结构指标(交付时须报实测数字,`wc -l` 口径)
| 指标 | 基线 | 目标 |
|---|---|---|
| `Approve` 行数 | **169** | **< 60** |
| `service/` 包内 >100 行的函数数 | **1** | **0** |
| `service/` 包内最大单函数行数 | **169** | **< 100**(实测次大者 83,故目标应落在 83–100 之间) |
| `moderation.go` 行数 | 298 | **不作指标**(D-H:helper 同文件,可能不降反升;报了即可,不设目标) |
| 既有 `*_test.go` 修改文件数 | — | **0** |
| `architecture_test.go` 修改行数 | — | **0** |
| `Approve` 的签名 | `(ctx context.Context, adminID, submissionID, note string) error` | **逐字不变** |
| 字节级比对 `missing` | — | **0** |
---
## §6 记账
- `crearte-server/docs/CHANGELOG.md` 新增 **`## [0.19.0] - 2026-10-03`**(插 `## [0.18.0]` 前)。格式:**同条目英文行紧跟中文行、无空行**;不同条目**空一行**;小节标题双语(`### Changed / 变更`、`### Tests / 测试`、`### Context / 背景`)。
- wrapper `docs/CHANGELOG.md` 新增 **`## [0.3.8] - 2026-10-03`**(插 `## [0.3.7]` 前),小节 `### Done / 完成`。
- `docs/ROADMAP.md`:第三波表新增 **P15-A 行**(P14 行之后)+ 文档索引表新增一行。**哈希引 merge commit**(P12/P13/P14 惯例),记账 commit 另列。
- **挂账新增**:① 六个 >60 行函数(`deleteAccountLocked` 83 / `UpdateSubmission` 76 / `ValidateWorkFields` 75 / `ImportDir` 70 / `CleanupService.Run` 70 / `checkSubmissionRules` 66)② **阈值 100 的盲区扩写为「≤100 行的任何合并形态」**(实测:⑥整体搬进 `applyApprovalInTx` = **77 行**不红;**仅合并④⑥ = 91 行仍不红**;真红下界 >100 行)③ **`TestApproveIsSmall` 的镜像盲区**:须内联四个阶段才把 `Approve` 推到 79 ≥ 60;单内联⑦(28 行)时三条守卫全绿(由 (c)/(d) 的名字集合断言兜)→ 守卫钉的是「编排体小 + 五个名字存在 + 无巨函数」,**不钉「每个阶段必须经由 helper」** ④ **非 `func` 形态的任意长度逃逸**(F2:125 行包级闭包四门全绿)⑤ **三个字面量常量的自证缺口**(F4:RV-S 复现「0 文件 0 违规 PASS」)⑥ **F3:四门配方的 gofmt 门退出码盲**(影响**所有**批次,不只 P15-A)⑦ **F5:P14 断言 5 的类型别名接收者绕过**(pre-existing)
- **`go.mod` / 任何 version 文件不 bump**。
- **纪律**:显式 `git add <file>`,**禁 `git add -A`**。wrapper 的 `IDENTITY.md`/`SOUL.md`/`USER.md` 三个未跟踪文件**非本批产物,绝不 stage**。
---
## §7 风险与缓解
| 风险 | 缓解 |
|---|---|
| **实现者"顺手 DRY"合并④⑥的 switch** | §1.2 已写明四处实质差异 + 语义差别(乐观/悲观)+ 错误文案差别;D-D 明令禁止;T3 字节级比对会暴露合并(⚠️ **但 T3 是一次性证据,报告归档后下一个贡献者合并 ④⑥ 时没有任何东西会红** → 由 `approve_phases_test.go` 的 **CG1–CG3 常驻断言**拦截,见 §5-T1.4)(base 的 `service: approve precheck: %w` 文案会消失或移位);既有 `TestApproveSameWorkIDConcurrent` 断言的错误集合会变 |
| **`plan` 值传递导致 ⑤ 的 `finalXxxKey` 静默丢失** | §3.1 已点名:`copyApprovalObjects` 必须收 `*approvePlan`。`TestApproveOptionalFieldsRoundTrip` 与 api 层发布链测试会抓到(key 为空 → 作品无 bundle) |
| **② 的 `owner` 零值被误当成"未加载"** | §3.2 已写明 `if sub.Kind == NewWork` 条件必须保留;`namespace_test.go:132 TestNewVersionMetadataChangeOwnership` 会抓到 OwnerID 错误 |
| **③ 的 `bundleUpload = &up` 取地址语义被改成存值** | §3.2 已点名;改了会让 `plan.bundleUpload != nil` 恒真或恒假 → ⑤⑥⑦ 全链路错,api 层发布链测试必红 |
| **⑦ 的 `slog.Info` 被搬进 cleanup helper** | §3.2 已明令留在 `Approve` 本体(职责清晰)。日志键名/顺序变更由 D-G 约束;若实现者搬了,代码审查阶段抓 |
| **新守卫 `TestNoOversizedFunction` 写成恒真/恒假** | mutation (e)(f) 双向验证(改扫描范围→红、改阈值→红)+ P13/P14 教训:`max(a,b) ≥ k` 是恒真高发区、`NumMethod()==0` 是恒假形态。**本批新增镜像形态须防:断言里若含"至少扫到 N 个文件"的前提检查,N 写太小会让前提恒成立而主体断言失去意义** |
| **未导出 helper 让 reflect 类断言失效** | §1.3 已实测:未导出方法不在断言 2/4 视野内 → 这是**特性不是缺陷**(重构私有实现不该触守卫)。但 T1.3 的 `TestApproveHelpersExist` 因此**必须用源码文本匹配而非 reflect** |
| **本批削弱 P14 守卫** | mutation (g)(h) 跨批回归检查(加字段→断言 6 红;加组合根方法→断言 5 红,**且须测有名与匿名两种接收者形态**,P14 FR-1 教训) |
---
## §8 实现纪律(P11–P14 累计教训,逐条来自真实事故)
1. **不推断,只实测。** 任何"应该是/大概/按惯例"都要跑一条命令确认。P14 控制者在勘查期与裁定期共犯 28 次断言/grep/锚点/区间/路径/正则错误。
2. **断言或 grep 返回意外值时,先怀疑自己的模式,别先宣布缺陷。** P14 任务级审查者的第一版比对脚本误报 `CoverURL` 为 CHANGED(字符串字面量里的 `//` 被当注释起点);控制者五次因这条纪律避免了指控子代理的假缺陷。
3. **修守卫必须两方向都验**:对合法值不误红 + mutation 下不误绿。P13 控制者只验前者,交付了数学恒真的断言(`max(填充侧,边框侧) ≥ 3`,互补两侧下界 = √cr(paper,ink) = 4.0621 > 3,50653 采样暴力验证)。
4. **全称声明需要全称范围的证据。** P14 控制者写"断言 5 是唯一能抓遮蔽的手段"被终审推翻(匿名接收者 `func (*ContentService) M()` 绕过);写"两层覆盖完整"被推翻(孤儿私有方法)。**写"唯一/全部/任何"之前先枚举形态**(有名/匿名接收者、值/指针、导出/未导出)。
5. **恒假断言与恒真断言同样无价值,且恒假更容易因"看起来严格"而通过审查。** P14 控制者 spike 否决的 `NumMethod()==0`(嵌入 `*T` 使方法进值+指针两个方法集,合法树上已是 22)就是恒假形态。
6. **管道会吞掉真退出码。** `go test ./... | tail -30; echo $?` 取的是 `tail` 的退出码。P14 终审者踩过(`full_exit=0` 而实际套件 FAIL)。
7. **`|| echo "(zero)"` 这类兜底文本在 glob 失败时会伪装成"测量结果为零"。** P14 终审者 R0 轮整轮结论无证据支撑,自查后在真仓用绝对路径重跑;控制者的 `bg-surface=0` 同形态(`cd` 到仓根却写 `app/...`,真根是 `src/app/...`)。**任何"零命中"结论都要先证明扫描范围非空。**
8. **长时命令落盘证据用 `setsid nohup` 脱离 + 宿主侧重定向。** exec session 一断容器就被杀,P14 实现者的 `t3-gates.txt` 首跑只写 1/12 包。
9. **`restore()` 只回滚 mutation 触及的文件,绝不用 `git checkout -- .`**;每轮恢复后核验守卫编辑仍在(`func Test` 计数),否则 FATAL 终止——让脚本在被自毁时大声失败,而不是静默产出"未按预期"的假结论。
10. **测试被迫修改 = 设计失败的信号。** 若必须改既有测试,**停下来报告**而不是改。本批的验收硬指标就是"既有 `*_test.go` 修改数 = 0"。
@@ -0,0 +1,429 @@
# P15-B 设计:bootstrap 加载屏暗色 + 对比度守卫补钉 + 死令牌清理(crearte)
- **日期**:2026-10-03
- **仓**:`crearte`(前端;`package.json` 与全部源码在 **`src/`**,不是仓根)
- **分支**:`feat/p15b-bootstrap-dark-and-guard-pins`(AGENTS.md 只允许 `{feat|fix|docs|chore}/`;本批含用户可感知的暗色改动,故用 `feat/`)
- **base**:`f823195`(master,P13 交付后的 AGENTS.md 不变量 commit)
- **维度**:用户体验 / UI 交互 + 代码优雅(USER.md 六维度循环)
- **并行**:P15-A 在 `crearte-server` 仓,两仓各自单写者 → **可同时派发**(`sdd-parallel-dispatch` §1)
---
## §0 基线(已实测,2026-10-03 13:07)
**vitest**:`Test Files 79 passed (79)` · `Tests 688 passed (688)` · Duration 44.83s。
命令:`cd crearte/src && npx vitest run`。**与 P13 交付时报的 688/79 逐字相同** → P14 未触及前端,且此后无新增测试。这是本批的比对基线。
`bootstrap/host-origin.test.ts (3 tests)` 已在 79 个文件里 → **`bootstrap/` 目录已有测试先例**,新增测试文件不属破例。
**vitest 环境**:`src/vite.config.ts` 的 `test` 段只有 `exclude: [...configDefaults.exclude, 'e2e/**']`,**无 `environment` 键** → 默认 **node**。这是 `contrast.test.ts` / `noHardcodedColor.test.ts` / `themeBootstrap.test.ts` 能用 `fileURLToPath(new URL('../..', import.meta.url))` 读源码文件的前提(**P13 教训:happy-dom 下会抛 `ERR_INVALID_URL_SCHEME`**)。本批新增的守卫**必须沿用同款写法**,不得引入需要 DOM 的断言。
**e2e**:`src/e2e/`,`playwright.config.ts` / `.noauth.` / `.stack.` 三份配置都是 `testDir: './e2e'`。`dark.spec.ts` **10 腿**(P13 交付),其中 `:100` 是 **pre-paint 归因腿**:「Vue 包被拦截时 `data-theme` 仍为 dark(证明是内联脚本而非挂载后设置)」。
---
## §1 三项改动的取证
### 1.1 改动①:bootstrap 游戏加载屏对暗色用户「白闪」
**现象**:`src/bootstrap/index.html`(虚拟模式游戏的加载屏,`<title>游戏加载中…</title>` + 进度条 + 状态行 + 错误区)把整套配色**硬编码为亮色值**,且 `localStorage` / `prefers-color-scheme` / `data-theme` / `@media` **四项全部零命中**。暗色用户启动虚拟游戏时,会在暗色页面里看到一块亮色矩形。
**它确实是用户面**(非死代码):
- `src/vite.config.ts:21` 把 `bootstrap/index.html` 注册为名为 `bootstrap` 的**第二个 Vite 构建入口**
- `runtime/sw/router.ts:1` `export const BOOTSTRAP_PATH = '/__bootstrap'`;`sw/index.ts:211,218` 有 `redirect-bootstrap` 与离线兜底
- `runtime/host/adapters.ts:38`:`targets.push({ mode: 'virtual', url: \`${origin}/__bootstrap#${hash.toString()}\`, origin })`
- `useGameFrame.test.ts`:virtual 模式 url 形如 `http://demo.localhost:4173/__bootstrap#v=1`
- 游戏跑在**独立子域**:`runtime/host/config.ts:18` `derivePlayOrigin → ${protocol}//${16hex}.${baseDomain}`,由 `GameHost.vue` 以 iframe 嵌入
**根因不是漏改,是 P13 的令牌化只覆盖了单入口**:P13 改的是 `src/index.html`(主应用入口)+ `app/styles/main.css`(12 令牌)+ `app/` 与 `runtime/` 的 `.vue`。`bootstrap/` 是**第二个 HTML 入口**,不在任何一处覆盖范围内。
→ **教训(已入 ROADMAP 挂账):换肤/令牌化类改动须先 `grep -n 'input' src/vite.config.ts` 枚举所有构建入口,逐个确认覆盖。单入口假设会漏掉多入口应用。**
**跨源约束(决定修法的上限)**:
- **`localStorage` 按源隔离** → bootstrap 跑在游戏子域,**读不到**主应用存在主域 `localStorage['crearte.theme.v1']` 的显式选择。主应用 `src/index.html:15-31` 的 pre-paint 脚本判定序是 `stored → prefers-color-scheme → light`(由 `themeBootstrap.test.ts` 7 腿钉住),**在子域上拿不到 `stored` 那一级**。
- **`prefers-color-scheme` 是浏览器级、不按源隔离** → 可用。
- **hash 通道已存在**:`bootstrap/main.ts:4` 就是 `const params = new URLSearchParams(location.hash.replace(/^#/, ''))`,且 `main.ts:97` 有 `fail('启动参数完整', location.href)` 说明它是必需参数集 → **主题可搭车现有 hash 通道传入,不必新建 postMessage 协议**(P15 账本 §7 原估「完整修法须 postMessage,成本更高」,**这条被证伪**)。
**🔑 架构陷阱(本批最重要的设计约束)**:`GameHost.vue:27-31` 的 `targets` 是 **`computed`**:
```js
const targets = computed<RuntimeTarget[]>(() => resolveRuntimeTargets(props.game, {
baseDomain: config.baseDomain, protocol: location.protocol, config
}))
```
而 `useTheme.ts:51` 是模块级响应式单例 `const theme: Ref<Theme> = ref(effectiveTheme())`。
| 注入方式 | 响应式追踪 | 后果 |
|---|---|---|
| ❌ `theme: useTheme().theme.value` | **是**(读 `ref` 的 getter → computed 建立依赖) | 用户在游戏页切主题 → `targets` 重算 → **iframe `src` 变化 → 正在运行的游戏被重载**(存档/进度丢失) |
| ✅ `theme: effectiveTheme()` | **否**(`effectiveTheme()` 内部读 `localStorage` 与 `matchMedia`,都不是响应式源) | computed 不依赖主题;iframe 不重载。代价:切主题后已打开的加载屏保持旧主题,直到下次导航 |
→ **裁定用 `effectiveTheme()`**(`useTheme.ts:39` 已导出)。"游戏运行中不重载" 比 "加载屏实时跟随主题切换" 重要得多:加载屏只在**启动瞬间**可见,而重载会毁掉正在进行的游戏。**这条必须有测试钉住**(§5-T1 **腿 5**;⚠️ RE-PIN 2026-10-03:原写「腿 4」,而腿 4 是 IIFE/var、注入是**腿 5**——spec 内部自相矛盾,§5-T1 表自身与 §5 mutation (d)、§7 风险表都写腿 5),否则下一个贡献者"顺手改成响应式"就会引入静默的游戏重载缺陷。
**色值映射(实测;`bootstrap/index.html` 里 8 个硬编码 hex 的令牌归属)**:
| hex | 令牌归属 | 暗色对应值 |
|---|---|---|
| `#f7f2e7` | 亮 `paper` **且** 暗 `ink`(翻转对称点) | `#17140f`(暗 paper) |
| `#141414` | 亮 `ink` | `#f7f2e7` |
| `#ffffff` | 亮 `surface` | `#221e18` |
| `#5c584d` | 亮 `ink-soft` | `#cbc4b5` |
| `#e8552f` | 亮 `accent` | `#ff7a4d` |
| `#c03a1b` | 亮 `accent-ink` | `#ffb59a` |
| `#a3a3a3` | ❌ **不对应任何令牌** | 见下 |
| `#f87171` | ❌ **不对应任何令牌** | 见下 |
**🔴 同批发现两条 pre-existing WCAG 违规(inline `style` 覆盖了达标的样式表值)**:
| 元素 | 样式表值(`<style>` 块) | inline `style=""` 值 | 谁生效 | 对比度(on `#f7f2e7`) |
|---|---|---|---|---|
| `#status`(`:25`) | `#5c584d` | **`#a3a3a3`** | **inline 胜**(CSS 特异性) | 样式表 **6.3586 ✅** / inline **2.2594 ❌** |
| `#error-message`(`:27`) | `#c03a1b` | **`#f87171`** | **inline 胜** | 样式表 **4.8700 ✅** / inline **2.4776 ❌** |
→ **inline 是冗余重复且用了更低的对比度**:样式表里同元素**已有达标值**,inline 属性把它覆盖成不达标的。**删掉 inline 的 `color` 声明即修复**(保留 `font-size`),达标值自动生效。这是有实测对比度支撑的一行改,**不是设计决策**(与 GameHost 亮色徽标那条不同——那条的显而易见修法已被证伪,属撞色设计决策,仍挂账)。
**暗色候选配色(8 项全部实算通过)**:
| 用途 | 暗色值 | on | 对比度 | 需 | 判定 |
|---|---|---|---|---|---|
| body 正文 | `#f7f2e7`(ink) | `#17140f`(paper) | **16.4507** | 4.5 | ✅ |
| `#status` / `pre` | `#cbc4b5`(ink-soft) | `#17140f` | **10.5847** | 4.5 | ✅ |
| `#error-message` | `#ffb59a`(accent-ink) | `#17140f` | **10.7821** | 4.5 | ✅ |
| 按钮文字 | `#17140f`(paper) | `#f7f2e7`(ink) | **16.4507** | 4.5 | ✅ |
| 进度条值 | `#ff7a4d`(accent) | `#221e18`(surface) | **6.4274** | 3.0 | ✅ |
| 进度条边框/阴影 | `#f7f2e7`(ink) | `#221e18` | **14.8468** | 3.0 | ✅ |
亮色现状(**须逐字保持不变**):body 正文 16.5010 ✅ · `#status` 样式表 6.3586 ✅ · `#error-message` 样式表 4.8700 ✅ · 按钮 16.5010 ✅ · 进度条值 `#e8552f` on `#ffffff` = 3.6385 ✅(需 3.0)。
### 1.2 改动②:`AA_PAIRS` 补钉 `['ink', 'surface']`
**`AA_PAIRS` 现状**(`app/lib/contrast.test.ts:102`,类型 `Array<[fg: string, bg: string, note: string]>`,**10 对**,断言循环在 `:124`):
```
['ink','paper','正文全站 亮16.50/暗16.45'] ['paper','accent-ink','badge/按钮/error toast 亮4.87/暗10.78']
['ink-soft','paper','次要文 亮6.36/暗10.58'] ['paper','success','success toast 亮4.76/暗5.81']
['ink-faint','paper','::placeholder+弱文 亮4.83/暗6.99'] ['paper','ink','.btn-ink/markdown th 亮16.50/暗16.45']
['ink-soft','surface','卡片次要文 亮7.10/暗9.55'] ['accent-ink','paper','链接 text-accent-ink 亮4.87/暗10.78']
['ink-faint','surface','卡片弱文 亮5.40/暗6.31']
['ink','highlight','alert×8/strong/选中态/::selection 亮11.30/暗4.81']
```
**缺口**:`ink on surface` **未钉**,而它是**所有未钉配对里使用面最大的**:
- `bg-surface` **`.vue` 模板内 62 处**(`app/` + `runtime/`);⚠️ 整个 `src` 是 **66 处**(另 4 处在 `.test.ts`:BaseInput/BaseTabs/BaseTextarea/FileInput 各 1)→ 原写「全仓 62 处」与 66 冲突(RE-PIN 2026-10-03)。**不影响结论**:Tailwind 只从 `.vue` 模板生成 utility,`.test.ts` 里的字面量属 §8-8 的另一回事(⚠️ RE-PIN 2026-10-04 补口径,闭合终审 §6-3:以上是 **base 树 `f823195`** 的计数;HEAD 树上整个 `src` 是 **67** 处、`.test.ts` 是 **5** 处——多的 1 处正是本批在 `contrast.test.ts` 的 AA_PAIRS note 文案里引用了这个数字。**结论不受影响**:Tailwind 只从 `.vue` 模板生成 utility,`.test.ts` 里的字面量属 §8-8 的另一回事)
- 其中只有 **4 处**显式写 `text-ink-soft`(已钉)、**2 处**的 `text-paper` 属三元表达式**另一支**(P15 账本 §1 第 19 次错误:把互斥分支当同元素共现,是假阳性)
- **58 处靠继承**:全局文字色源是 `src/index.html:34` `<body class="bg-paper text-ink font-sans antialiased">` → 继承到 **`ink`**
- 实测对比度:亮 **18.4225** / 暗 **14.8468** → **必然通过 4.5**
**为什么值得钉(不是"必然通过就不用钉")**:已钉配对里使用面最大的是 `paper on accent-ink`(14 处)。`ink on surface` 有 62 处却无守卫 → 若将来调 `--color-surface`(如暗色 `#221e18` 提亮)或 `--color-ink`,**58 处继承文字会静默回归而零拦截**。这正是 P13/P14 反复出现的形态:**守卫钉住了容易想到的,漏了使用面最大的**。
**note 文案**(体例同现有 10 条,须含用途 + 亮/暗实测值):
```
['ink', 'surface', '卡片/表格/表单底(62 处,58 靠 body 继承)亮18.42/暗14.85'],
```
### 1.3 改动③:`--color-info` 死令牌清理
**定义**:`app/styles/main.css:12`(亮 `#2b62cc`)、`:53`(暗 `#7aa7f0`)。
**零使用**(实测):全仓 `bg-info` / `text-info` / `border-info` / `ring-info` **零命中**;`color-info` 在整个 `src/` + `e2e/` 里**只有那两行定义**(命中数 = 2,即定义本身)。`main.css` 内 `info` 字样也只出现在那两行。
**矛盾点**:它在 `REQUIRED_KEYS`(`contrast.test.ts:75-88`,**12 项**,`info` 在**第 9 位**(第 8 是 `highlight`;⚠️ RE-PIN 2026-10-03:原写第 8 位))里 → `it(`${zh} 12 令牌齐备(spec §3.1(b))`)`(`:119`)会**在删除时变红**。**守卫在强制一个零使用的令牌存在。**
**连带面(实测清点,5 个文件,不是"顺手删两行")**:
| 文件 | 行 | 改什么 |
|---|---|---|
| `crearte/src/app/styles/main.css` | 12, 53 | 删两行定义 |
| `crearte/src/app/lib/contrast.test.ts` | 84 | 从 `REQUIRED_KEYS` 删 `'info',` |
| 同上 | 47 | 注释「由「12 令牌齐备」断言明确报「暗色 12 令牌缺失」」→ 改 11 |
| 同上 | 74 | 注释「P13 全量 12 令牌(spec §3.1(b))」→ 改 11 |
| 同上 | 119 | `it` 标题 `${zh} 12 令牌齐备(spec §3.1(b))` → 改 11 |
| 同上 | 121 | 断言消息 `${zh} 12 令牌缺失: …` → 改 11 |
| `crearte/docs/CHANGELOG.md` | 14(0.26.0 段) | 「每主题 **12 个设计令牌**」→ 须说明 0.19.0 起为 11(**历史条目不改写**,改为在新条目里说明;见 D-C) |
| `wrapper/docs/specs/2026-10-03-p13-dark-mode-design.md` | 109, 155, 185, 427 | P13 spec 的 D-B「全量 12 令牌」+ 两处 `--color-info` 代码块 + §「暗色块缺失 → 「暗色 12 令牌齐备」红」→ **加 RE-PIN 标注**(同 P14 手法:保留历史决策 + 标注增订),不改写原文 |
| `wrapper/docs/ROADMAP.md` | 42(P13 行) | P13 行的「每主题 **12 个设计令牌**」→ 保留原文(历史记录),挂账里删掉「死令牌 `--color-info`」这一项(已处置) |
**✅ `LEGACY_KEYS` 不受影响**(实测):`contrast.test.ts:197` 的九键反面钉桩是 `['paper','surface','ink','ink-soft','ink-faint','accent','accent-ink','highlight','success']`,**不含 `info`** → 删令牌**不波及** P13 那条"防后人简化回去"的钉桩。这条必须实测确认过再动手,否则会误改一处刻意保留的历史锚点。
**为什么不选"启用它"**(处置 (b)):`success` 与 `highlight` 已覆盖 toast 与 alert 的语义需求(`paper on success` 是 success toast、`ink on highlight` 覆盖 alert×8/strong/选中态/`::selection`),`info` **无对应 UI 位置**。启用它需要新造一个 info toast 变体 = 改视觉 + 加功能,超出"代码优雅"范围,且会为一个新的 UI 元素引入新的对比度配对需要钉。**删比造便宜,且删掉的是零使用面。**
---
## §2 设计决策
| # | 决策点 | 裁定 | 依据 |
|---|---|---|---|
| **D-A** | bootstrap 主题来源 | **hash 参数 `theme` 优先 → `prefers-color-scheme` → `light`**(三级,比主应用少 `stored` 一级) | §1.1:`localStorage` 按源隔离读不到;hash 通道已存在(`main.ts:4`);`prefers-color-scheme` 不受源隔离。**判定序必须与主应用 `useTheme.effectiveTheme()` 的后两级逐字一致**,否则同一用户在主应用与游戏加载屏看到不同主题 |
| **D-B** | 父侧注入方式 | **`effectiveTheme()` 非响应式快照**,**不得**用 `useTheme().theme.value` | §1.1 的架构陷阱:响应式会让主题切换重算 `targets` → iframe `src` 变 → **运行中的游戏被重载**。必须有测试钉住(§5-T1 **腿 5**;RE-PIN:原写腿 4) |
| **D-C** | 死令牌清理的历史条目处理 | **不改写 P13 的 CHANGELOG 条目与 spec 原文**,改为:新条目(0.27.0)说明变更 + P13 spec 加 **RE-PIN 标注块** | CHANGELOG 是发布历史,改写会让"当时交付了什么"失真。P14 已建立正确手法:保留历史决策原文 + 紧随带 ⚠️ RE-PIN 标记的增订块(全分支终审判定这是"正确的 re-pin 手法"而非自相矛盾) |
| **D-D** | 新守卫的形态 | **新增 `app/lib/bootstrapTheme.test.ts`**(源码级、node 环境、读文件文本),**不扩展 `noHardcodedColor.test.ts`** | §1.4:`noHardcodedColor` 的 `candidates()` 只收 `.vue`(`if (file.endsWith('.vue'))`),且 `maskNonTemplate()` 把 `<style>` 块整体等长空白掉 → **它从设计上无法覆盖 `bootstrap/index.html`**(HTML 文件 + 颜色全在 `<style>` 里)。扩扫描根不会有任何效果 |
| **D-E** | inline `style` 的两处违规 | **删掉 inline 的 `color` 声明**(保留 `font-size`),让样式表的达标值生效 | §1.1 实测:inline 是冗余重复且对比度更低(2.2594 / 2.4776 vs 样式表 6.3586 / 4.8700)。删 inline 即修复,**同时消除"两处定义同一元素颜色"的分歧源** |
| **D-F** | 暗色实现方式 | 在 `bootstrap/index.html` 的 `<style>` 块里用 **`html[data-theme="dark"]` 覆盖**(同主应用 `main.css:44` 的手法),**不用 `@media (prefers-color-scheme)`** | 主应用用 `data-theme` 属性驱动(`color-scheme` 也由它驱动,D-L),使"显式选择能压过系统偏好"。bootstrap 若用 `@media` 就只能跟随系统、无法接收 hash 传入的显式主题 → **两套机制会让判定序无法一致**(违反 D-A) |
| **D-G** | pre-paint 脚本形态 | **IIFE + 仅 `var`,置于 `<head>` 内、任何 CSS 与 `<body>` 之前**,**形态**逐字对齐主应用 `src/index.html:15-31`(IIFE / 仅 `var` / 位置 / catch 兜底 / 媒体查询字面量五项)——⚠️ **脚本体本身不同**:主应用多 stored 一级(D-A 明列),token 级差异 16 处;RE-PIN 2026-10-04 闭合终审 §6-5,原措辞「逐字对齐…的写法」会被读成脚本体也相同 | 主应用该形态由 `themeBootstrap.test.ts` 腿 4("脚本在 `<head>` 内、`<body>` 之前")与腿 5(IIFE + var)钉住,理由是 CSP 落地前不能用 module/let。**同款形态让两处可被同一套守卫断言** |
| **D-H** | 派发形态 | **一个实现者子代理**做完 T1→T4(同动 crearte 单一工作树与 git index) | AGENTS.md / `sdd-parallel-dispatch` §1「一仓一写者」;P12/P13/P14 同款 |
### §2.1 被否方案
| 方案 | 否决理由 |
|---|---|
| **A:用 `@media (prefers-color-scheme: dark)` 实现 bootstrap 暗色** | 只跟随系统偏好,**无法接收 hash 传入的用户显式选择** → 显式设了暗色但系统是亮色的用户仍看到亮色加载屏。且与主应用的 `data-theme` 机制分叉,判定序无法逐字一致(违反 D-A)。这是 P15 账本 §7 原估的"廉价修法",**取证后判定不够** |
| **B:postMessage 传主题** | hash 通道已存在(`main.ts:4`),无需新协议。postMessage 是**异步**的,而加载屏是 pre-paint → 会先绘亮色再翻暗色,**正是要消除的白闪**。`host-origin.ts` 的信令通道用于安装进度上报,时序上不适合主题 |
| **C:给 bootstrap 也读 `localStorage`** | 源隔离,物理上读不到主域的值(`derivePlayOrigin` → 独立子域)。若为绕过而放宽子域隔离,会破坏 P11 的安全模型 |
| **D:扩展 `noHardcodedColor` 的扫描根到 `bootstrap/`** | §1.4:`candidates()` 只收 `.vue`、`maskNonTemplate()` 会空白掉 `<style>` 块 → 扩根**扫不到任何东西**,且会给人"已覆盖"的假信心(P13 审查者 M5 的同族形态:扫描根改坏后"扫 0 文件也报 0 违规") |
| **E:把 bootstrap 的配色改成引用 `var(--color-*)` 令牌** | 令牌定义在 `app/styles/main.css`,**bootstrap 是独立入口、不加载主应用 CSS**(它只有自己的 `<style>` 块)。要引令牌就得把整个 `@theme` 块复制进 bootstrap = 两处调色板需要同步维护,比硬编码更糟。故 bootstrap **刻意**保持自包含的 hex 值,由新守卫钉住两主题的值与主应用调色板一致 |
| **F:一并修 GameHost 亮色徽标 WCAG 3.26** | 属**设计决策**而非一行改:亮色 `accent` vs `accent-ink` 仅 **1.4943**,徽标与「加载失败」状态点同屏共存于展柜标题栏,改成 `bg-accent-ink` 会把两个语义压成一个视觉信号(已实测证伪显而易见修法,ROADMAP `9965fd7` 已登记约束)。仍需挂账 |
### §1.4 `noHardcodedColor` 为何无法覆盖 bootstrap(D-D 的依据,逐字取证)
```js
// app/lib/noHardcodedColor.test.ts:23-56(⚠️ RE-PIN 2026-10-03:原标 :23-45,但下方引文一直引到 :56 的 maskNonTemplate 结束——而 maskNonTemplate 恰是 D-D 论证的**关键一半**「<style> 块整体空白掉」,旧标注把最有说服力的部分排除在外。另:引文块内的 ← 旁注是**控制者批注**,不在原文里)
const SRC_ROOT = fileURLToPath(new URL('../..', import.meta.url))
// 终审 N4:扫描根必须含 `runtime/`。…实测 runtime/ 当前零硬编码 hex,扩根不会立刻红。
const SCAN_ROOTS = [join(SRC_ROOT, 'app'), join(SRC_ROOT, 'runtime')]
function candidates(): string[] {
const files: string[] = []
for (const root of SCAN_ROOTS) {
for (const file of walk(root)) {
if (file.endsWith('.test.ts')) continue
if (file.endsWith('.vue')) files.push(file) // ← 只收 .vue
}
}
return files
}
function maskNonTemplate(content: string): string {
const blank = (block: string): string => block.replace(/[^\n]/g, ' ')
return content
.replace(/<!--[\s\S]*?-->/g, blank)
.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, blank) // ← <style> 块整体空白
.replace(/<(textarea|title)\b[^>]*>[\s\S]*?<\/\1>/gi, blank)
}
```
→ 两道过滤各自都足以排除 `bootstrap/index.html`:**扩展名不是 `.vue`**,且**颜色全在 `<style>` 块内**(会被 `blank` 掉)。该守卫的设计目标是"禁止在 `.vue` **模板**里用 `bg-[#…]` 形态的硬编码 hex"(文件头注释原文),HTML 入口的 `<style>` 块本就不在其范围内。
---
## §3 实现要求
### 3.1 改动① bootstrap 暗色(`src/bootstrap/index.html` + `src/runtime/host/adapters.ts` + `src/runtime/host/GameHost.vue`)
**(a) `bootstrap/index.html` 的 `<head>`**:在 `<style>` **之前**插入 pre-paint 脚本,逐字对齐主应用形态(D-G):
```html
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#F7F2E7" />
<!-- P15-B D-A/D-G:加载屏 pre-paint 主题脚本,须在样式与文档体之前执行,否则暗色用户每次启动游戏都闪白。
判定序比主应用少 stored 一级(游戏跑在独立子域,浏览器存储按源隔离,读不到主域的 crearte.theme.v1):
hash theme → prefers-color-scheme → light。后两级与 useTheme.effectiveTheme() 逐字一致,
一致性由 app/lib/bootstrapTheme.test.ts 钉住(改一处必须改另一处)。 -->
<script>
(function () {
try {
var params = new URLSearchParams(location.hash.replace(/^#/, ''))
var hashTheme = params.get('theme')
var theme =
hashTheme === 'light' || hashTheme === 'dark'
? hashTheme
: window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
document.documentElement.setAttribute('data-theme', theme)
var meta = document.querySelector('meta[name="theme-color"]')
if (meta) meta.setAttribute('content', theme === 'dark' ? '#17140f' : '#f7f2e7')
} catch (e) {
document.documentElement.setAttribute('data-theme', 'light')
}
})()
</script>
```
> ⚠️ **RE-PIN 2026-10-03(本段是控制者批注,不属于上面可复制的代码块)**:上面注释的措辞**刻意避开** `<style`、`<body`、`localStorage` 三个字面量。实现者与审查者**双双实测**:原措辞「必须在 `<style>` 与 `<body>` 之前」会让**腿 1 对正确实现假红**(注释自身在脚本之前 → `indexOf('<style')` 先命中注释;审查者复现 styleOpen=145 < scriptPos=811),而注释里的存储键字样会让 §5 指标「bootstrap 内该项 = 0」**字面不达标**。**对照**:主应用 `src/index.html` 的注释刻意写「必须在 **CSS 与 Vue** 之前」正是为了避开这两个字面量,所以 `themeBootstrap.test.ts` 的 naive indexOf 才不误红 —— 原措辞违反了这一既有范式。**照抄上面代码块时不要把这批注抄进去。** 🔴 **RE-PIN 2026-10-04 更正(终审 F-FINAL-1)**:本批注描述的「避开三个字面量」范式**仍然成立且必要**,但当时三方都以为 `maskSourceComments()` 已经把注释这一整类载体中和了——**实测并非如此**:该函数的 HTML 注释分支用 2 字符切片去和 4 字符的 `<!--` 比较,恒 false = **死代码**,故它此前只屏蔽 `//` 与 CSS 块注释两种载体。后果是「守卫不依赖措辞自律」这句声称不成立:仅仅把注释措辞改成含 `<style` / `<body` 就会让腿 1–4 假红(同变异跑 e2e 9/9 全绿,证明红的是守卫不是实现)。修法(1 行新增 + 1 处比较对象)已由第二轮 fix-forward 执行并验牙。
⚠️ **白名单校验必须与主应用同款**(`=== 'light' || === 'dark'`),不得放宽成"任意 truthy"——`themeBootstrap.test.ts` 腿 3 正是为此存在(P13 finding #2 / N2:垃圾 `stored=neon` 必须被忽略)。
**(b) `<style>` 块**:加暗色覆盖块(D-F),值取 §1.1 的暗色调色板;`color-scheme: dark` 随块声明一次(同 `main.css:57` 的手法与理由)。**亮色色值逐字不变、渲染结果等价**(⚠️ RE-PIN 2026-10-03:裁定**接受**偏离 4——亮色值改由具名 `--bs-*` 变量供给、暗色块覆盖同名变量。理由三条:① 审查者独立写 CSS 解析器验证亮色态 **15 条颜色相关声明零差异**(含 4 条简写内嵌 `var()` 的 border/box-shadow)② 手法与主应用 `main.css` 同构(`@theme` 定义令牌 + `html[data-theme="dark"]` 覆盖),符合 D-F ③ 严格守「文本形态逐字不变」则暗色块须重写 **12 条规则**,同步维护面从 6 个值涨到 12 条规则)。
**(c) 两处 inline `style`**(D-E):`:25` 删 `color:#a3a3a3`、`:27` 删 `color:#f87171`,各保留 `font-size`。删后由 `<style>` 块的 `#status`(`#5c584d`) 与 `#error-message`(`#c03a1b`) 生效 → 对比度 6.3586 / 4.8700 达标。
**(d) `adapters.ts:10,37-38`**:`resolveRuntimeTargets` 的 `opts` 加**可选**键 `theme?: 'light' | 'dark'`;仅当 `opts.theme` 存在时 `fragment.theme = opts.theme`(**不加就不写这个键**,保持 external/hosted 模式的 url 完全不变)。
```ts
export function resolveRuntimeTargets(game: Game, opts: {
baseDomain: string; protocol: string; config?: ReturnType<typeof runtimeConfig>; theme?: 'light' | 'dark'
}): RuntimeTarget[] {
```
⚠️ **必须可选**:`adapters.test.ts` 有 **11 处**调用不带 `theme`(8 处裸 `opts` + 3 处 `{...opts, config}`;⚠️ RE-PIN 2026-10-03:原写「4 处」,实测 `resolveRuntimeTargets(` 出现 **11** 次、含 `theme:` **0** 次、按 `test(` 而非 `it(` 组织 → 没有任何口径得出 4),且 `useGameFrame.test.ts` 也 import 了 `RuntimeTarget` 类型 → 加必填键会破既有测试(违反"既有测试修改数 = 0")。
**(e) `GameHost.vue:27-31`**:注入非响应式快照(D-B):
```js
import { effectiveTheme } from '../../app/composables/useTheme'
const targets = computed<RuntimeTarget[]>(() => resolveRuntimeTargets(props.game, {
baseDomain: config.baseDomain, protocol: location.protocol, config, theme: effectiveTheme()
}))
```
⚠️ **不得写成 `useTheme().theme.value`**(会让 computed 依赖 theme ref → 主题切换重载 iframe)。注释须写明这个陷阱,否则下一个贡献者会"顺手改成响应式"。
### 3.2 改动② `AA_PAIRS` 补钉(`src/app/lib/contrast.test.ts:102-113`)
在 `['ink','highlight',…]` 之后(或按现有分组习惯置于 `surface` 两对之后)插入:
```ts
['ink', 'surface', '卡片/表格/表单底(bg-surface 62 处,58 靠 body 继承 text-ink)亮18.42/暗14.85'],
```
**不得改其它 10 对**(它们的 note 里带实测值,是 P13 交付的一部分)。
### 3.3 改动③ 死令牌清理(5 文件,见 §1.3 连带面表)
- `main.css:12`(亮)与 `:53`(暗)删两行 `--color-info`
- `contrast.test.ts`:`REQUIRED_KEYS` 删 `'info',`(`:84`)+ 四处 "12 令牌" 文案改 "11 令牌"(`:47,74,119,121`)
- ⚠️ **`LEGACY_KEYS`(`:197`)不动**(九键不含 `info`,已实测)
- P13 spec 加 RE-PIN 标注块(D-C,不改写原文)
- crearte CHANGELOG **不改写 0.26.0 条目**,在 0.27.0 新条目里说明
### 3.4 不得触碰
- `themeBootstrap.test.ts`(主应用 pre-paint 守卫,7 腿)——除非某腿因本批变红,那时**停下来报告**
- `noHardcodedColor.test.ts`(D-D:扩它无用)
- `useTheme.ts`(`effectiveTheme()` 已够用,改它会波及主应用 10 腿 e2e)
- `src/index.html`(主应用入口,P13 交付)
- 任何既有 `*.test.ts` / `e2e/*.spec.ts`
- `crearte-server`、`crearte-deploy`、wrapper 的 `docs/`(实现者红线;wrapper 的 spec/CHANGELOG/ROADMAP 由**控制者**记账)
---
## §4 边界
| 项 | 在范围内 | 不在范围内 |
|---|---|---|
| 文件 | `bootstrap/index.html`、`runtime/host/adapters.ts`、`runtime/host/GameHost.vue`、`app/lib/contrast.test.ts`、`app/styles/main.css`、**新增** `app/lib/bootstrapTheme.test.ts`、**新增** `e2e/bootstrapTheme.spec.ts`(RE-PIN 2026-10-04,闭合终审 §6-4:该文件 294 行 / 9 腿、fix 轮 +162 −13,§5-T4.3 有它的完整测试计划而 §4 的范围内清单漏列) | 其它 `.vue` / `useTheme.ts` / `themeBootstrap.test.ts` / `noHardcodedColor.test.ts` |
| 主题 | bootstrap 加载屏的两态 | GameHost 展柜框架本身(它吃主应用令牌,已正确跟随暗色;其亮色徽标 3.26 属设计决策,挂账) |
| 守卫 | 新增 bootstrap 主题一致性守卫 + `AA_PAIRS` 补一对 | 改既有守卫的断言语义 |
| 令牌 | 删 `--color-info` | 新增任何令牌 |
| 行为 | 加载屏配色 + hash 多一个可选键 | 任何游戏运行时行为、SW 路由、bundle 解密 |
**挂账**(本批发现但刻意不做):GameHost 亮色徽标 WCAG 3.26(设计决策,撞色约束已登记 ROADMAP `9965fd7`)· 第三态"恢复跟随系统"(P13 spike 3 已证伪:header 在 @320px 最坏格无像素容纳带标签控件)· CSP 落地时 bootstrap 内联脚本也需 `nonce`(与主应用同批处理)· SFC `<style>` 块内硬编码 hex 守卫不覆盖(P13 accepted:`app/` 无 `<style>` 块)。
---
## §5 测试计划
### T1 —— 守卫先行(**RED 先行**):新增 `app/lib/bootstrapTheme.test.ts`
源码级守卫,**node 环境**(§0:vitest 默认 node,沿用 `fileURLToPath(new URL('../..', import.meta.url))` 读文件,与 `contrast.test.ts` / `themeBootstrap.test.ts` / `noHardcodedColor.test.ts` 同款)。
读三个文件:`bootstrap/index.html`、`app/composables/useTheme.ts`、`app/styles/main.css`。
**腿清单**(每条都要有 mutation 证明有牙):
| # | 断言 | 钉什么 | mutation(应变红) |
|---|---|---|---|
| 1 | bootstrap 的 `<head>` 内有 pre-paint 脚本,且在 `<style>` 与 `<body>` **之前** | D-G 的 pre-paint 位置(否则暗色用户每次启动闪白) | 把 `<script>` 移到 `<style>` 之后 |
| 2 | bootstrap 判定序 = **hash theme → prefers-color-scheme → light**,且用 `indexOf` 比较三者位置 | D-A 的判定序 | 交换 hash 与 prefers 的先后;或删掉 light 兜底 |
| 3 | bootstrap 用**白名单**校验 hash theme(只接受 `light`/`dark`) | 防"任意 truthy"放宽(P13 finding #2 / N2 的同族) | 改成 `hashTheme ? hashTheme : …` |
| 4 | 脚本是 **IIFE 且只用 `var`**(无 `let`/`const`/箭头函数/`import`) | D-G 的 CSP 前形态 | 把 `var` 改成 `const` |
| 5 | **`GameHost.vue` 注入的是 `effectiveTheme()` 而非 `useTheme().theme.value`** | 🔴 **D-B 的架构陷阱**(响应式会让主题切换重载运行中的游戏) | 改成 `theme.value` → 必须红 |
| 6 | bootstrap 暗色块的 hex 值与 `main.css` 的 `html[data-theme="dark"]` 调色板**逐值一致**(`paper`/`surface`/`ink`/`ink-soft`/`accent`/`accent-ink` 六个) | D-E 方案否决理由:bootstrap 自包含 hex,故须钉"两处调色板不分叉" | 把 bootstrap 暗色 `--paper` 改成别的值 |
| 7 | bootstrap **亮色调色板的值**未被改动 —— ⚠️ **须从 `:root` 块提取 `--bs-*` 再逐值比对(与腿 6 对称),不得用全文子串搜索**(RE-PIN 2026-10-03:审查者 F-4 实测 `#f7f2e7` 在文件里出现 **3 次**(`:root` 的 `--bs-paper`、暗色块的 `--bs-ink`、脚本的 theme-color meta)→ 全文搜索**必然命中**;把 `:root` 亮色 paper 改成 `#eeeeee` 时 `RED=[]`,且**四个守卫合跑 38 passed / 0 failed 全绿** = 本腿的守卫目的「防顺手把亮色也改了」**无断言覆盖**,是 P14「spec D-J 守卫目的无断言覆盖」的同族形态。改为按 scope 提取后**同时消除「暗色块 `--bs-ink` 与亮色 `--bs-paper` 同值」造成的耦合**,即 P13 `LEGACY_KEYS` 钉桩里「scrim 两主题同值 → 后写覆盖不可观测,故排除」的同一类推理) | 防"顺手把亮色也改了"(本批只加暗色) | 改 `:root` 的亮色 paper |
| 8 | `bootstrap/index.html` 内**不存在 inline `style` 里的 `color:`**(D-E 的回归钉桩)—— ⚠️ **修法必须覆盖双引号 / 单引号 / 无引号三种属性形态**(RE-PIN 2026-10-04,闭合终审 F-FINAL-4):**提取所有 `style=` 属性值、去引号后逐条查 `color:`**,即已实现的 `inlineStyleValues()`。🔴 **正则字面量刻意不写进本表格**——markdown 表格单元格里的竖线必须转义,而转义后的竖线在正则里是**字面量字符**而非「或」;控制者上一轮正是这样把一条本来正确的正则抄成 **4/7 形态失配**(比裁定原文的 2/7 更错),而它检查了「表格每行竖线数 == 表头」、**没检查转义后的代码是否还是原来那段代码**。要看正则请读代码实体 `src/app/lib/bootstrapTheme.test.ts` 的 `inlineStyleValues()`。⚠️ **不要写成「先匹配 style 属性、再往后找 `color:`」的一行正则**:实测该形态在单引号与无引号上失配——贪婪的引号内匹配会吃掉整个属性值(含内部的 `color:`),闭引号之后剩余文本以尖括号开头 → 失配。与 P13「`bg-[#…]` 手写反斜杠转义对 minified 假阴性」、控制者「带引号 grep 对 minified 假阴性」**同族:模式只覆盖了自己见过的那种写法** | 防 inline 违规复发(本批刚删两处 2.2594 / 2.4776) | 加回 `style="color:#a3a3a3"`、**或单引号形态、或无引号形态** |
| 9 | **前提检查**:扫描到的文件数 ≥ 3、bootstrap html 含 `<head>` / `<style` / `<body` **三个结构性标记**、且长度 **> 4000**(⚠️ 单位是**源码字符 4423**,不是产物 7056 字符 / 7588 字节) | 防"读 0 文件也报 0 违规"的假信心(P13 审查者 M5/M6、P14 终审者 R0 的同族教训)。**RE-PIN 2026-10-04**(闭合终审 §6-1):原写「长度 > 500」是 F-8 修**前**的旧值——500 对 4423 而言宽松到截断至 501 字符仍绿(mutation K4 实测),代码已改为 4000 并补了三个结构性前提 | 把路径改成不存在的文件;**或截断到 501 字符**(腿9 应红——修前它仍绿) |
| 10 | `AA_PAIRS` 含 `['ink','surface']` 且 `REQUIRED_KEYS` **不含** `info`、长度为 **11** | 改动②③ 的回归钉桩 | 删掉补钉的那一对;或把 `info` 加回 |
**RED 相位要求**:T1 在 T2/T3/T4 之前提交时,`npx vitest run app/lib/bootstrapTheme.test.ts` 必须**红**,且**至少腿 1/2/5/6/8/10 红**(bootstrap 还没有脚本、没有暗色块、GameHost 还没注入、AA_PAIRS 还没补、info 还在)。腿 7/9 应当**已绿**(亮色值本来就在、文件本来可读)——**这是有意的**:它们是本批的"不得回退"钉桩,不是新功能的断言。实现者须把 RED 输出**逐字存证**到 `.superpowers/sdd-p15b/impl-evidence/t1-red.txt`,并**逐腿标注红/绿**。
⚠️ **恒真/恒假审视(P13/P14 累计教训,每条腿都要过)**:
- 腿 2 用 `indexOf` 比较位置时,**若某个 `indexOf` 返回 -1(未找到),`-1 < 任何正数` 会让顺序断言恒真** → 必须先断言三者都 `> -1`(`themeBootstrap.test.ts:42-44` 正是这么写的,照抄这个手法)
- 腿 6 的"逐值一致"**不得写成 `max(a,b) ≥ k` 形态**(P13 的恒真高发区:互补两侧有非平凡下界);应写成**字符串相等**
- 腿 10 的长度断言须是 `toBe(11)` 而非 `toBeGreaterThanOrEqual(…)`(后者在删令牌后仍绿 = 无牙)
- 🔴 **(⚠️ RE-PIN 2026-10-03 补:原三条遗漏的第四类恒真形态)循环变量集合自身可被清空 → `for` 执行零次 = 恒真空绿**。审查者 **H4** 实测:清空 `DARK_TOKENS` 后腿 6 的 `for` 零次执行、`RED=[]`,**而退化是真的**——这是 P13 审查者 **M5**(扫描根改 e2e → 扫 0 个 `.vue`)/ **M6**(`HEX_RE` 改恒不匹配)的**精确同族**。**修法:每条以集合驱动的腿都须加 `expect(LIST.length).toBe(N)`**(腿 6 加 `DARK_TOKENS.length === 6`、腿 7 加 `LIGHT_HEX.length === 6`,与腿 10 的 `toBe(11)` 同款手法)
### T2 —— 改动②③(守卫腿 10 由红转绿)
按 §3.2 / §3.3 执行。**完成后 `npx vitest run` 必须 79 文件 / 688+N 测试全绿**(N = T1 新增的腿数),且**既有测试文件零修改**。
⚠️ 改动③会让 `contrast.test.ts` 的 `it` 标题从 "12 令牌齐备" 变 "11 令牌齐备" → **测试名变了但文件是"修改"而非"新增"**。这是**唯一被允许的既有测试文件修改**,且必须在报告里显式声明(同 P14 的 `architecture_test.go` 例外处理)。除此之外 `--diff-filter=M -- '*_test.ts'` 必须为空。
### T3 —— 改动①(bootstrap 暗色 + hash 注入)
按 §3.1 执行。完成后:
- `npx vitest run` 全绿
- `npm run typecheck`(`vue-tsc --noEmit`)零错
- `adapters.test.ts` 的既有 **11 处**调用**零修改**仍绿(验证 `theme` 是可选键)
### T4 —— e2e 与构建产物验证
1. `npm run build`(含 `vue-tsc --noEmit` + `vite build` + `build-runtime.mjs`)exit 0
⚠️ **`npm run build` 会类型检查 `e2e/*.spec.ts`**(P13 spike 6 曾因此 `BUILD_EXIT=2`)
2. `npm run e2e` 主套件 **102+1skip** 基线不回退(P13 交付值;`dark.spec.ts` 10 腿须全绿)
3. **新增 e2e 腿**(⚠️ RE-PIN 2026-10-03:**偏离 5 已被裁定驳回**,本项按原意执行,不接受「fixture 不支持所以不测」):(a) **端到端腿**——暗色 + 虚拟游戏页,用 **`context.route`(不是 `page.route`)** + inert sw.js 冻住加载屏,断言 **`snap.url === 父侧 iframe src`**(**因果链闭合**:子文档读到的就是父侧算出的那个 url,不是测试手搓的 hash)、`attr=dark`、`bg=rgb(23,20,15)`、h1 是加载屏、`theme-color` meta = `#17140f`、**+8s 后 `path` 仍是 `/__bootstrap`**;另加亮色格(`stored=light` 压过系统暗色)。🔴 **根因纠正**:实现者四种拦截手法**全用 `page.route`**,审查者 PROBE-6A 实测**三个 route 命中数全为 0**(SW 注册请求、SW 发起的请求、被 SW `respondWith` 合成的导航都**不经 page 级路由**)→ 「挂起从未生效,真 SW 照常安装 → `runtime:ready` → `location.replace('/')`」被误归因为「加载屏本质瞬态」。改 `context.route` 后 PROBE-7A `sw.js hits = 1`、子文档稳定停在 `/__bootstrap`;PROBE-7B **T+0/T+10s/T+25s 三次全 `attr=dark`**,T+25s 因 Playwright 自身 `timeout: 45_000` 中断而**不是被顶掉**。(b) **行为腿**——游戏页切主题 → iframe `src` **不变**(同一 inert-SW 手法下可确定性断言):这是 D-B 那个 critical 性质的**唯一行为级防线**(腿 5 改回全文断言后仍有残留局限,见 §5-T1 腿 5)。(c) **反方向格**——子侧 `[系统暗色] × [hash=light]` → 期望 **`light`**(6 行):审查者实测 **H1**(白名单提取成变量 + prefers 优先)与 **H3**(拆中间变量的嵌套三元)都让腿 2 全绿,而**缺陷是真的**(显式选亮色的暗色系统用户看到暗色加载屏,违反 D-A「hash 优先」);且 **H1 同时逃过源码守卫(10 passed)与全部 5 条 e2e 腿(5 passed)**。缺失的正是这一格,它是 H1/H3 在**子侧直访**路径上的唯一鉴别格(⚠️ RE-PIN 2026-10-04,闭合终审 §6-2:实测判定序真缺陷会让**两条**腿红——本格**与端到端亮色格**,故「唯一」只在子侧直访这一类里成立;原措辞是纪律 #4 惩罚的全称声明)。对照:主应用 `dark.spec.ts:169` **有**这条反方向腿(「stored 压过 system(反方向)」),bootstrap 侧缺
4. **产物验证**(P13 教训:源码级守卫不等于产物正确):⚠️ **必须在 e2e 之后重跑生产 `npm run build` 之上做**——`npm run e2e` 内部跑 `build:e2e`(`playwright.config.ts:12` 的 `webServer.command`)会**覆盖 `dist/`**,并用 `grep -c -F 'localhost:4173' src/dist/bootstrap/index.html` = **0** 证明量的是生产构建而非 e2e 残留(⚠️ RE-PIN 2026-10-03:控制者当日正是量了 e2e 残留,导致它的 7069 与实现者的 7056 本就不该相等而被当成矛盾;实现者报告 §2 已明写这个顺序陷阱)。判据:`dist/bootstrap/index.html` **含暗色块**、**不含** `#a3a3a3` 与 `#f87171`;主应用 CSS 里 `--color-info` **零命中**、两个色值(`2b62cc`/`7aa7f0`)**零命中**;**定义侧**令牌数 **12→11**(源码 `@theme` 与 `html[data-theme="dark"]` 各 11、产物暗色块 11);**usage 侧** `var(--color-*)` 计数与 P13 交付值 **75 保持不变**——`info` 是**零 usage 的死令牌**(7 种 utility 后缀 `bg-`/`text-`/`border-`/`ring-`/`fill-`/`stroke-`/`outline-` 全部零命中),删它**必然**不改变 usage 计数,这是预期行为而非缺陷。🔴 **判据方向**:若 usage 计数**发生变化**,才说明存在对 `info` 的引用,与 §1.3 的「零使用」取证前提矛盾,须停下来重新取证。**定义侧与 usage 侧是两个口径,不得混用**(⚠️ RE-PIN:原判据「删一个令牌应使计数变化」方向写反了,由实现者发现、审查者独立复现成立)。⚠️ **产物 grep 用 `grep -F`**,不手写转义、不用带引号模式(minifier 会去属性引号:产物是 `data-theme=dark` 而非 `data-theme="dark"`;CSS 类名是**转义**形态 `.bg-\[\#…\]`)。⚠️ **报数字必须同时报口径**:字节用 `wc -c`(**不要用 `len(str)` 标注成 B**,那是字符数,差值精确等于 UTF-8 多字节贡献)、计数**同时给 raw 与屏蔽注释后两个数**并声明大小写敏感性、行数用 `wc -l`
⚠️ **产物 grep 用 `grep -F` 不手写转义**(P13 教训:`.backdrop\:bg-scrim` 手写反斜杠转义对 minified 产物假阴性;AGENTS.md 已立不变量)
### mutation 自查(实现者必做并附输出,每条测"预期红 + 其余绿 + 恢复证明")
- (a) 把 bootstrap 的 pre-paint `<script>` 移到 `<style>` 之后 → 腿 1 红
- (b) 交换 hash theme 与 `prefers-color-scheme` 的判定先后 → 腿 2 红
- (c) 把 hash theme 校验放宽成 `hashTheme ? hashTheme : …` → 腿 3 红
- (d) `GameHost.vue` 改用 `useTheme().theme.value` → 腿 5 红(**这条是 D-B 的牙**)
- (e) 改 bootstrap 暗色的一个 hex → 腿 6 红
- (f) 加回 `style="color:#a3a3a3"` → 腿 8 红
- (g) 从 `AA_PAIRS` 删掉补钉的 `['ink','surface']` → 腿 10 红
- (h) 把 `info` 加回 `REQUIRED_KEYS` → 腿 10 红
- (i) **把守卫的文件路径改成不存在的文件** → 腿 9 红(防"读 0 文件报 0 违规")
- (j) **删掉 `main.css` 的暗色块**(模拟"P13 成果回退")→ 腿 6 或 `contrast.test.ts` 的暗色腿红
每次 mutation 后**只回滚触及的文件**(`git checkout -- <file>`,**绝不用 `git checkout -- .`**——P14 控制者第 24 次自伤:全量回滚抹掉了未提交的守卫编辑,导致后续各轮全在测没有修复的树),并核 `git diff` 空 + 守卫文件腿数不变。
### 结构指标(交付时须报实测数字)
| 指标 | 基线 | 目标 |
|---|---|---|
| vitest 文件数 / 测试数 | **79 / 688** | **80 / 688+N**(N = T1 腿数,≥10) |
| 既有 `*.test.ts` 修改文件数 | — | **1**(仅 `contrast.test.ts`,且仅因 "12 令牌"→"11 令牌" 文案;须在报告显式声明) |
| `AA_PAIRS` 对数 | 10 | **11** |
| `REQUIRED_KEYS` 项数 | 12 | **11** |
| `LEGACY_KEYS` 项数 | 9 | **9**(不动) |
| `--color-info` 全仓命中 | 2(两处定义) | **0** |
| bootstrap 内 `localStorage`/`prefers-color-scheme`/`data-theme` 命中 | **0 / 0 / 0** | **0 / ≥1 / ≥1**(`localStorage` 仍须为 0:源隔离,D-A) |
| bootstrap 内 inline `style` 的 `color:` 数 | 2 | **0** |
| `typecheck` / `build` | 0 / 0 | **0 / 0** |
| 主 e2e | 102+1skip | **≥102+1skip**(新增腿另计) |
---
## §6 记账
- `crearte/docs/CHANGELOG.md` 新增 **`## [0.27.0] - 2026-10-03`**(插 `## [0.26.0]` 前)。格式:**同条目英文行紧跟中文行、无空行**;不同条目**空一行**;小节标题双语。
- wrapper `docs/CHANGELOG.md` 新增 **`## [0.3.8] - 2026-10-03`**(插 `## [0.3.7]` 前),小节 `### Done / 完成`。**与 P15-A 合并为一条还是分两条由控制者按合并时序定**(若两批同时合并,写一条含两仓的条目更清楚)。
- `docs/ROADMAP.md`:第三波表新增 **P15-B 行** + 文档索引表新增一行。**哈希引 merge commit**。挂账里删掉「死令牌 `--color-info`」(已处置)、新增「bootstrap 内联脚本的 CSP nonce」(与主应用同批)。
- P13 spec 加 **RE-PIN 标注块**(D-C:12→11 令牌),不改写原文。
- **纪律**:显式 `git add <file>`,**禁 `git add -A`**。wrapper 的 `IDENTITY.md`/`SOUL.md`/`USER.md` **绝不 stage**。
---
## §7 风险与缓解
| 风险 | 缓解 |
|---|---|
| **`theme` 键写成必填 → 破既有测试** | §3.1(d) 明令可选;`adapters.test.ts` **11 处**调用零修改仍绿是验收项 |
| **🔴 注入改成响应式 → 主题切换重载运行中的游戏** | D-B + §3.1(e) 的 ⚠️ 注释 + **T1 腿 5 与 mutation (d) 专门钉这条** + **§5-T4.3(b) 的行为腿**(RE-PIN 2026-10-03)。⚠️ **腿 5 只钉字面形态,不够**:审查者 F-1(critical)实测 **G1**(调用点字面量与交付版**逐字相同**、依赖在 computed 体内别处建立)与 **G2**(import 别名 + 本地同名响应式包装)**双双绕过**(10 腿全绿、`vue-tsc` exit 0),而用 `effectScope` + `computed` 求值计数**证明缺陷语义上真实**(基准 `evals=1`/url 不变;G1/G2 均 `evals=2`、url 从 `…theme=light` 翻成 `…theme=dark` → **iframe `src` 变化 → 运行中的游戏被重载、存档丢失**)。**修法(一行 ×2)**:腿 5 的两条负向断言从 `args`(调用点内)改回 `gameHostMasked`(已屏蔽注释的**全文**)——审查者逐字验证 `maskSourceComments()` **本身已解决**「陷阱注释必然写出被禁字面量」的冲突(🔴 **RE-PIN 2026-10-04 限定适用范围,终审 F-FINAL-1**:这句对**当前这个文件**是真的,但成立原因是「陷阱注释恰好是 `//` 行注释形态」,**不是**「屏蔽了注释」这一整类——该函数的 HTML 注释分支当时是死代码。故 F-1 的修法方向正确、必须做,而它的**安全论证**依赖一个只对 `//` 成立的泛化,那个泛化已被 C5/G1x 实测推翻)(屏蔽后 `useTheme()` 与 `theme.value` 都从文件消失、`effectiveTheme` 真实代码保留、陷阱注释 28–33 行屏蔽后全空)→ **缩小断言范围不必要,第二道防线反而制造了缺口**(与 P14 终审「在第一轮修复自身里找到洞」完全同构)。已验五种组合:交付形态+G1/G2 = 绿(绕过);改法+G1/G2 = **红(抓到)**;改法+合法树 = **10 腿全绿(不误红)**。⚠️ **残留局限须写进守卫注释、不得声称「唯一手段」**(纪律 #4):改后仍抓不住「在 computed 之外的**模块级作用域**读 ref 再传值」,但该形态**无害**(快照在模块加载时求值一次、无依赖 → 不会重载);真正的解药是行为腿 |
| **判定序与主应用分叉 → 同一用户两处不同主题** | T1 腿 2 钉 bootstrap 内部顺序;**另需一条腿比对 `useTheme.effectiveTheme()` 的后两级顺序**(`themeBootstrap.test.ts` 腿 2 的同款手法:用 `indexOf` 比较,且先断言三者都 `> -1`) |
| **bootstrap 与主应用调色板分叉**(bootstrap 自包含 hex) | T1 腿 6 逐值比对六个暗色 hex + 腿 7 钉亮色值不变。**这是 D-E 方案(引令牌)被否后的必要补偿** |
| **新守卫写成恒真** | §5-T1 的 ⚠️ 三条(`indexOf` 返 -1 时顺序断言恒真、`max(a,b) ≥ k` 形态、`toBeGreaterThanOrEqual` 无牙)+ mutation (i) 腿 9 前提检查 + (a)–(h) 逐条验牙 |
| **inline 违规复发** | T1 腿 8 + mutation (f)。**本批发现的两处是 pre-existing(P13 之前就存在),修完须有钉桩**,否则下一次改 bootstrap 又会加回来 |
| **产物与源码不一致**(源码级守卫绿但产物错) | §5-T4.4 产物验证。P13 教训:`APPLY.toString()` + `new Function` 序列化注入验证构建期烘焙是**无效手法**;必须验真实 `dist/` |
| **e2e fixture 无虚拟游戏 → hash 注入无覆盖** | ⚠️ RE-PIN 2026-10-03:**该风险的前提被证伪**——审查者实测加载屏**可**确定性冻结(`context.route` + inert sw.js,稳定 ≥25s),并已写出跑绿 2 条端到端腿(8.9s + 0.6s)。原「退路」(改 vitest 腿挂 `GameHost`)**删除**;§5-T4.3 的「必须有一腿覆盖」按原意执行。**快照存档不能替代断言**(快照是一次性观测、不会自己变红)——与 P13 教训同一枚硬币的两面:那边是「产物验证不能替代源码守卫」,这边是「**存档观测不能替代 CI 断言**」 |
---
## §8 实现纪律(P11–P14 累计教训,与 P15-A spec §8 同源,前端部分加粗)
1. **不推断,只实测。** P13 控制者在勘查期栽两次(**用运行时注入验证构建期烘焙**、**用 `APPLY.toString()`+`new Function` 序列化注入**),复核期写错断言/grep/锚点 13 次。
2. **断言或 grep 返回意外值时,先怀疑自己的模式,别先宣布缺陷。** P14 任务级审查者的第一版比对脚本误报 `CoverURL` 为 CHANGED(字符串字面量里的 `//` 被当注释起点);控制者六次因这条纪律避免假指控(含 `bg-surface=0`、`main.css 无全局色`、`七类断言=0`)。
3. **修守卫必须两方向都验**:对合法值不误红 + mutation 下不误绿。P13 控制者只验前者,交付了数学恒真的断言(`max(填充侧,边框侧) ≥ 3`,互补两侧下界 = √cr(paper,ink) = **4.0621 > 3**,50653 采样暴力验证)。
4. **全称声明需要全称范围的证据。** P14 控制者写"断言 5 是唯一能抓遮蔽的手段"被终审推翻(匿名接收者 `func (*ContentService) M()` 绕过)。**写"唯一/全部/任何"之前先枚举形态。**
5. **恒假断言与恒真断言同样无价值,且恒假更容易因"看起来严格"而通过审查。** P14 被否决的 `NumMethod()==0`(嵌入 `*T` 使方法进值+指针两个方法集,合法树上已是 22)。
6. **产物 CSS 选择器用 `grep -F`,不手写转义。** P13:`.backdrop\:bg-scrim` 手写反斜杠转义对 minified 产物假阴性;**带引号的 grep 模式对 minified 产物也假阴性**(`grep 'bg-[#]'` 匹配不到 `bg-[#123456]` 的压缩形态)。已立为 crearte AGENTS.md 不变量。
7. **源码级守卫跑 node 不跑 happy-dom。** `fileURLToPath(new URL('../..', import.meta.url))` 在 happy-dom 下抛 `ERR_INVALID_URL_SCHEME`。已立为 AGENTS.md 不变量;§0 已确认本仓 vitest 无 `environment` 键 → 默认 node。
8. **Tailwind v4 扫描全部源文件含 `.test.ts`/`.spec.ts`**:测试注释里的类名字面量会被当候选、烧死 utility 进产物。P13 实现者用拼接修对一处,控制者八分钟后在自己裁定 commit 里重新引入。已立为 AGENTS.md 不变量 §8-5b。**本批写守卫时,注释里不要出现完整的 `bg-[#…]` / `text-[#…]` 字面量**(用字符串拼接或省略号)。
9. **管道会吞掉真退出码**;**`grep -c` 输出 0 时 exit 1**,放 `&&` 链里会静默中断后续步骤(含 `git commit`)。
10. **`restore()` 只回滚 mutation 触及的文件,绝不用 `git checkout -- .`**;每轮恢复后核验守卫编辑仍在,否则 FATAL 终止。
11. **`|| echo "(zero)"` 这类兜底文本在 glob 失败时会伪装成"测量结果为零"** → 任何"零命中"结论都要先证明扫描范围非空(T1 腿 9 就是这条的机械化)。
12. **测试被迫修改 = 设计失败的信号**,除非 spec 明列例外(本批唯一例外:`contrast.test.ts` 的 "12→11 令牌" 文案)。若发现必须改其它测试,**停下来报告**。
13. 🔴 **(RE-PIN 2026-10-03,审查者 F-2)手法的适用范围没验证就当已穷尽**:`page.route` **拦不到** SW 注册请求、SW 发起的请求、以及被 SW `respondWith` 合成的导航 → 冻住 SW 驱动的加载屏须用 **`context.route`**。**用错层级会让「挂起」静默失效,并把结果误归因为「被测对象本质瞬态」**——实现者四种手法全用 `page.route`(实测命中 0),据此得出「加载屏无法冻结、端到端腿必然 flaky」并撤掉了腿,而归因是错的。与第 6 条(带引号 grep 对 minified 假阴性)**同族**。**换一层再试一次,再下结论。**
14. **(RE-PIN 2026-10-03,审查者 F-9)报数字必须同时报口径**:字节用 `wc -c`(**不要用 `len(str)` 标注成 B**——那是字符数,差值精确等于 UTF-8 多字节贡献:CSS 12、html 532);计数**同时给 raw 与屏蔽注释后两个数**并声明大小写敏感性(控制者用 raw、实现者用屏蔽注释+大小写不敏感 → 同一份产物两份互斥数字、第三方无法判定谁错);行数用 `wc -l`(不要 `split('\n')`);**产物验证须在 e2e 之后重跑生产 build**(`npm run e2e` 内部跑 `build:e2e` 会覆盖 `dist/`)。**没有口径的数字无法交叉核对,等于没报。**
15. **一次性验证脚本一律落盘存证**(控制者的 CSS 解析器没落盘 → 结论虽被审查者独立复现,但**实现不可审计**)。**适用例补全(RE-PIN 2026-10-04,终审 §8.2):census / 统计类脚本同样必须落盘** —— 前任的 arbitrary-utility census 脚本没落盘,导致「48 vs 47」这个差 1 的口径争议**至今无法对齐**(终审用三份口径独立数都得 48,判据「真 hex 色 = 0」三方一致,故结论不受影响,但差异本身不可追溯)。
16. 🔴 **(RE-PIN 2026-10-04,终审 §8.7)报告里引用的每一份证据文件,交付前须逐个 `ls` 核在盘上;引用别处输出须给出该输出的落盘路径,路径不存在即视为未验证。** 这条纪律本轮**两次生效**:fix 者靠它抓到自己的假取证(把审查者的 PROBE 数字写成自己的实测结论——**是靠核「文件在不在盘上」抓到的,不是靠核结论对不对,因为结论恰好是对的**);终审者靠同一手法抓到控制者 `verify-fix-v4.py` 的 §③ 是 21 条无条件 `print('[OK]')`、且它引用的 v2 输出从未归档 → 「0 FAIL」结论虽真却不自足。**落盘纪律的价值不在结论正确性,而在可审计性。**
17. 🔴 **(RE-PIN 2026-10-04)spec 与裁定里凡可执行的技术细节(正则、函数签名、字面量、行号区间、对比度数字)必须从代码实体 `grep` / `git show` 出来粘贴,或自己按公式复算,不得凭记忆手写。** 本批控制者因此错了三处:① 把审查者建议的一行正则抄进 §5-T1 腿8,而 markdown 表格的竖线转义让它变成 **4/7 形态失配**(比裁定原文的 2/7 更错)② 漏 re-pin 腿9(spec 仍写 > 500 而代码已是 4000)③ 引用的对比度 `3.0269` 从未被任何人复核,自算实为 **2.5519**(反查任何灰底都得不到 3.0269 → 那是个孤值)。与 P15-A 的同族错误(形参顺序、阶段区间行号)合并为一条纪律,两份 spec §8 同源。