Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de480ee9bb | ||
|
|
bad67fa5a8 | ||
|
|
a82af6939f |
@@ -0,0 +1,14 @@
|
||||
* text=auto
|
||||
/.gitattributes text eol=lf
|
||||
/.gitignore text eol=lf
|
||||
/gradlew text eol=lf
|
||||
*.bat text eol=crlf
|
||||
*.json text eol=lf
|
||||
*.sh text eol=lf
|
||||
*.kt text eol=lf
|
||||
*.kts text eol=lf
|
||||
*.mjs text eol=lf
|
||||
*.md text eol=lf
|
||||
*.properties text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.yml text eol=lf
|
||||
@@ -0,0 +1,29 @@
|
||||
name: verify
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
harness:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@v7
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v6
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
- name: Set up Node.js 22
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "22"
|
||||
package-manager-cache: false
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
- name: Run local quality gates
|
||||
run: ./gradlew verifyLocal --no-daemon
|
||||
@@ -0,0 +1,14 @@
|
||||
.gradle/
|
||||
**/build/
|
||||
.idea/
|
||||
*.iml
|
||||
local.properties
|
||||
captures/
|
||||
|
||||
# Signing material and local credentials must never enter the repository.
|
||||
*.jks
|
||||
*.keystore
|
||||
keystore.properties
|
||||
secrets.properties
|
||||
.env
|
||||
.env.*
|
||||
@@ -0,0 +1,48 @@
|
||||
# Brainwave agent guide
|
||||
|
||||
This file is the short repository entry point. The detailed, authoritative map is
|
||||
[`docs/README.md`](docs/README.md).
|
||||
|
||||
## Start here
|
||||
|
||||
1. Read `docs/agent-playbook.md` before changing files.
|
||||
2. Read `docs/environment.md` before build, SDK, device, or dependency work.
|
||||
3. Read the relevant product/domain/architecture document from `docs/README.md`.
|
||||
4. Follow `docs/implementation-plan.md`; never silently turn an item in
|
||||
`docs/decisions.md#未决问题` into a product fact.
|
||||
|
||||
## Discovery
|
||||
|
||||
Prefer the configured codebase knowledge-graph tools in this order:
|
||||
`search_graph`, `trace_path`, `get_code_snippet`, `query_graph`, then
|
||||
`get_architecture`. Fall back to `rg` for literals, non-code files, or when those
|
||||
tools are unavailable or insufficient. State the fallback in the handoff.
|
||||
|
||||
## Non-negotiable domain rules
|
||||
|
||||
- The three physical coins are entered manually; do not add random casting.
|
||||
- `字 = 2`, `背 = 3`; record six lines from bottom to top.
|
||||
- Sums `6` and `9` move; sums `7` and `8` remain static.
|
||||
- King Wen numbering is a lookup table, never `binary + 1`.
|
||||
- Casting code remains pure Kotlin with no Android, Compose, database, or network
|
||||
dependency.
|
||||
- AI is opt-in explanation only. Do not place provider secrets in the app or repo.
|
||||
|
||||
## Current build boundary
|
||||
|
||||
The repository uses `brainwave` only as a code name. Formal product name,
|
||||
organization-owned application ID, and final `minSdk` remain product decisions.
|
||||
Until they are accepted, the `app` project is a Kotlin/JVM domain harness, not a
|
||||
publishable Android application. Do not invent placeholder release identity.
|
||||
|
||||
## Verification
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```powershell
|
||||
.\gradlew.bat verifyLocal --offline
|
||||
```
|
||||
|
||||
After the Android application plugin is configured, also run the Android gates
|
||||
listed in `docs/quality-gates.md`. Report skipped device or Android checks
|
||||
explicitly; do not imply they passed.
|
||||
@@ -0,0 +1,54 @@
|
||||
import groovy.json.JsonSlurper
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(17)
|
||||
|
||||
sourceSets {
|
||||
main {
|
||||
kotlin.srcDir("src/main/java")
|
||||
}
|
||||
test {
|
||||
kotlin.srcDir("src/test/java")
|
||||
}
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnit()
|
||||
testLogging {
|
||||
events("failed", "skipped")
|
||||
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("verifyDependencyLicenses") {
|
||||
group = "verification"
|
||||
description = "Ensures every resolved JVM runtime/test artifact has a reviewed license entry."
|
||||
|
||||
doLast {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val manifest = JsonSlurper().parse(rootProject.file("config/dependency-licenses.json")) as Map<String, Any>
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val components = manifest.getValue("components") as List<Map<String, Any>>
|
||||
val reviewedCoordinates = components.mapNotNull { it["coordinates"] as String? }.toSet()
|
||||
val resolvedCoordinates = configurations.getByName("testRuntimeClasspath")
|
||||
.resolvedConfiguration
|
||||
.resolvedArtifacts
|
||||
.map { artifact ->
|
||||
val id = artifact.moduleVersion.id
|
||||
"${id.group}:${id.name}:${id.version}"
|
||||
}
|
||||
.toSet()
|
||||
val missing = resolvedCoordinates - reviewedCoordinates
|
||||
check(missing.isEmpty()) {
|
||||
"Dependencies missing reviewed license entries: ${missing.sorted().joinToString()}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package brainwave.core.model
|
||||
|
||||
data class HexagramContent(
|
||||
val kingWenNumber: Int,
|
||||
val name: String,
|
||||
val symbol: String,
|
||||
val judgmentOriginal: String,
|
||||
val judgmentPlain: String,
|
||||
val lineTextsBottomUp: List<String>,
|
||||
val linePlainBottomUp: List<String>,
|
||||
val specialUsageText: String?,
|
||||
val sourceRefs: List<String>,
|
||||
) {
|
||||
init {
|
||||
require(kingWenNumber in 1..64) { "King Wen number must be between 1 and 64" }
|
||||
require(name.isNotBlank()) { "Hexagram name must not be blank" }
|
||||
require(symbol.isNotBlank()) { "Hexagram symbol must not be blank" }
|
||||
require(judgmentOriginal.isNotBlank()) { "Original judgment must not be blank" }
|
||||
require(judgmentPlain.isNotBlank()) { "Plain judgment must not be blank" }
|
||||
require(lineTextsBottomUp.size == 6 && lineTextsBottomUp.all(String::isNotBlank)) {
|
||||
"Original line texts must contain six non-blank bottom-up entries"
|
||||
}
|
||||
require(linePlainBottomUp.size == 6 && linePlainBottomUp.all(String::isNotBlank)) {
|
||||
"Plain line texts must contain six non-blank bottom-up entries"
|
||||
}
|
||||
require(specialUsageText == null || specialUsageText.isNotBlank()) {
|
||||
"Special usage text must be non-blank or null"
|
||||
}
|
||||
require(sourceRefs.isNotEmpty() && sourceRefs.all(String::isNotBlank)) {
|
||||
"Source references must contain at least one non-blank id"
|
||||
}
|
||||
require(sourceRefs.distinct().size == sourceRefs.size) {
|
||||
"Source references must be unique"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package brainwave.data.content
|
||||
|
||||
import brainwave.core.model.HexagramContent
|
||||
|
||||
interface HexagramContentRepository {
|
||||
val contentVersion: String
|
||||
|
||||
fun contentFor(
|
||||
kingWenNumber: Int,
|
||||
requestedContentVersion: String = contentVersion,
|
||||
): HexagramContent
|
||||
}
|
||||
|
||||
class ContentIntegrityException(message: String) : IllegalStateException(message)
|
||||
@@ -0,0 +1,80 @@
|
||||
package brainwave.domain.casting
|
||||
|
||||
data class CastComputation internal constructor(
|
||||
val roundsBottomUp: List<CastRound>,
|
||||
val lineValuesBottomUp: List<LineValue>,
|
||||
val primaryPatternBottomUp: HexagramPattern,
|
||||
val movingLinePositions: List<Int>,
|
||||
val transformedPatternBottomUp: HexagramPattern,
|
||||
val primaryHexagramId: HexagramId,
|
||||
val transformedHexagramId: HexagramId,
|
||||
)
|
||||
|
||||
object CastEngine {
|
||||
const val METHOD_VERSION = "coin-v1"
|
||||
const val COIN_CONVENTION = "character=2;reverse=3;bottom-up"
|
||||
|
||||
fun cast(roundsBottomUp: List<CastRound>): CastComputation {
|
||||
require(roundsBottomUp.size == HexagramPattern.LINE_COUNT) {
|
||||
"A complete cast must contain exactly six rounds, but contained ${roundsBottomUp.size}"
|
||||
}
|
||||
|
||||
val rounds = roundsBottomUp.toList()
|
||||
val lineValues = rounds.map(CastRound::lineValue)
|
||||
val primaryPattern = HexagramPattern.of(lineValues.map(LineValue::polarity))
|
||||
val movingPositions = lineValues.mapIndexedNotNull { index, lineValue ->
|
||||
if (lineValue.isMoving) index + 1 else null
|
||||
}
|
||||
val transformedPattern = HexagramPattern.of(lineValues.map(LineValue::transformedPolarity))
|
||||
|
||||
return CastComputation(
|
||||
roundsBottomUp = rounds,
|
||||
lineValuesBottomUp = lineValues,
|
||||
primaryPatternBottomUp = primaryPattern,
|
||||
movingLinePositions = movingPositions,
|
||||
transformedPatternBottomUp = transformedPattern,
|
||||
primaryHexagramId = HexagramCatalog.idFor(primaryPattern),
|
||||
transformedHexagramId = HexagramCatalog.idFor(transformedPattern),
|
||||
)
|
||||
}
|
||||
}
|
||||
data class CastMetadata(
|
||||
val contentVersion: String,
|
||||
val createdAt: String,
|
||||
) {
|
||||
init {
|
||||
require(contentVersion.isNotBlank()) { "Content version must not be blank" }
|
||||
require(createdAt.isNotBlank()) { "Created-at metadata must not be blank" }
|
||||
}
|
||||
}
|
||||
|
||||
data class CastResult private constructor(
|
||||
val methodVersion: String,
|
||||
val coinConvention: String,
|
||||
val roundsBottomUp: List<CastRound>,
|
||||
val lineValuesBottomUp: List<LineValue>,
|
||||
val primaryPatternBottomUp: HexagramPattern,
|
||||
val movingLinePositions: List<Int>,
|
||||
val transformedPatternBottomUp: HexagramPattern,
|
||||
val primaryHexagramId: HexagramId,
|
||||
val transformedHexagramId: HexagramId,
|
||||
val contentVersion: String,
|
||||
val createdAt: String,
|
||||
) {
|
||||
companion object {
|
||||
fun record(computation: CastComputation, metadata: CastMetadata): CastResult =
|
||||
CastResult(
|
||||
methodVersion = CastEngine.METHOD_VERSION,
|
||||
coinConvention = CastEngine.COIN_CONVENTION,
|
||||
roundsBottomUp = computation.roundsBottomUp.toList(),
|
||||
lineValuesBottomUp = computation.lineValuesBottomUp.toList(),
|
||||
primaryPatternBottomUp = computation.primaryPatternBottomUp,
|
||||
movingLinePositions = computation.movingLinePositions.toList(),
|
||||
transformedPatternBottomUp = computation.transformedPatternBottomUp,
|
||||
primaryHexagramId = computation.primaryHexagramId,
|
||||
transformedHexagramId = computation.transformedHexagramId,
|
||||
contentVersion = metadata.contentVersion,
|
||||
createdAt = metadata.createdAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package brainwave.domain.casting
|
||||
|
||||
data class CastRecordDto(
|
||||
val schemaVersion: Int,
|
||||
val methodVersion: String,
|
||||
val coinConvention: String,
|
||||
val roundsBottomUp: List<List<Int>>,
|
||||
val lineValuesBottomUp: List<Int>,
|
||||
val primaryPatternBottomUp: String,
|
||||
val movingLinePositions: List<Int>,
|
||||
val transformedPatternBottomUp: String,
|
||||
val primaryHexagramId: Int,
|
||||
val transformedHexagramId: Int,
|
||||
val contentVersion: String,
|
||||
val createdAt: String,
|
||||
) {
|
||||
fun toDomain(): CastResult {
|
||||
require(schemaVersion == CURRENT_SCHEMA_VERSION) {
|
||||
"Unsupported cast record schema version: $schemaVersion"
|
||||
}
|
||||
require(methodVersion == CastEngine.METHOD_VERSION) {
|
||||
"Unsupported casting method version: $methodVersion"
|
||||
}
|
||||
require(coinConvention == CastEngine.COIN_CONVENTION) {
|
||||
"Unsupported coin convention: $coinConvention"
|
||||
}
|
||||
|
||||
val rounds = roundsBottomUp.map(CastRound::fromScores)
|
||||
val result = CastResult.record(
|
||||
computation = CastEngine.cast(rounds),
|
||||
metadata = CastMetadata(contentVersion = contentVersion, createdAt = createdAt),
|
||||
)
|
||||
|
||||
require(lineValuesBottomUp == result.lineValuesBottomUp.map(LineValue::score)) {
|
||||
"Stored line values do not match the preserved coin rounds"
|
||||
}
|
||||
require(primaryPatternBottomUp == result.primaryPatternBottomUp.encoded()) {
|
||||
"Stored primary pattern does not match the preserved coin rounds"
|
||||
}
|
||||
require(movingLinePositions == result.movingLinePositions) {
|
||||
"Stored moving-line positions do not match the preserved coin rounds"
|
||||
}
|
||||
require(transformedPatternBottomUp == result.transformedPatternBottomUp.encoded()) {
|
||||
"Stored transformed pattern does not match the preserved coin rounds"
|
||||
}
|
||||
require(primaryHexagramId == result.primaryHexagramId.value) {
|
||||
"Stored primary hexagram id does not match the King Wen lookup"
|
||||
}
|
||||
require(transformedHexagramId == result.transformedHexagramId.value) {
|
||||
"Stored transformed hexagram id does not match the King Wen lookup"
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CURRENT_SCHEMA_VERSION = 1
|
||||
|
||||
fun fromDomain(result: CastResult): CastRecordDto =
|
||||
CastRecordDto(
|
||||
schemaVersion = CURRENT_SCHEMA_VERSION,
|
||||
methodVersion = result.methodVersion,
|
||||
coinConvention = result.coinConvention,
|
||||
roundsBottomUp = result.roundsBottomUp.map { round -> round.coins.map(CoinSide::score) },
|
||||
lineValuesBottomUp = result.lineValuesBottomUp.map(LineValue::score),
|
||||
primaryPatternBottomUp = result.primaryPatternBottomUp.encoded(),
|
||||
movingLinePositions = result.movingLinePositions.toList(),
|
||||
transformedPatternBottomUp = result.transformedPatternBottomUp.encoded(),
|
||||
primaryHexagramId = result.primaryHexagramId.value,
|
||||
transformedHexagramId = result.transformedHexagramId.value,
|
||||
contentVersion = result.contentVersion,
|
||||
createdAt = result.createdAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package brainwave.domain.casting
|
||||
|
||||
enum class CoinSide(val score: Int) {
|
||||
CHARACTER(2),
|
||||
REVERSE(3),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromScore(score: Int): CoinSide =
|
||||
entries.singleOrNull { it.score == score }
|
||||
?: throw IllegalArgumentException("Coin score must be 2 or 3, but was $score")
|
||||
}
|
||||
}
|
||||
enum class Polarity {
|
||||
YIN,
|
||||
YANG,
|
||||
;
|
||||
|
||||
fun opposite(): Polarity = if (this == YIN) YANG else YIN
|
||||
}
|
||||
|
||||
enum class LineValue(
|
||||
val score: Int,
|
||||
val polarity: Polarity,
|
||||
val isMoving: Boolean,
|
||||
) {
|
||||
OLD_YIN(6, Polarity.YIN, true),
|
||||
YOUNG_YANG(7, Polarity.YANG, false),
|
||||
YOUNG_YIN(8, Polarity.YIN, false),
|
||||
OLD_YANG(9, Polarity.YANG, true),
|
||||
;
|
||||
|
||||
val transformedPolarity: Polarity
|
||||
get() = if (isMoving) polarity.opposite() else polarity
|
||||
|
||||
companion object {
|
||||
fun fromScore(score: Int): LineValue =
|
||||
entries.singleOrNull { it.score == score }
|
||||
?: throw IllegalArgumentException("Line score must be between 6 and 9, but was $score")
|
||||
}
|
||||
}
|
||||
|
||||
data class CastRound private constructor(val coins: List<CoinSide>) {
|
||||
init {
|
||||
require(coins.size == COINS_PER_ROUND) {
|
||||
"Each cast round must contain exactly $COINS_PER_ROUND coins, but contained ${coins.size}"
|
||||
}
|
||||
}
|
||||
|
||||
val lineValue: LineValue
|
||||
get() = LineValue.fromScore(coins.sumOf(CoinSide::score))
|
||||
|
||||
companion object {
|
||||
const val COINS_PER_ROUND = 3
|
||||
|
||||
fun of(coins: List<CoinSide>): CastRound = CastRound(coins.toList())
|
||||
|
||||
fun of(first: CoinSide, second: CoinSide, third: CoinSide): CastRound =
|
||||
of(listOf(first, second, third))
|
||||
|
||||
fun fromScores(scores: List<Int>): CastRound = of(scores.map(CoinSide::fromScore))
|
||||
|
||||
fun fromLineValue(lineValue: LineValue): CastRound =
|
||||
when (lineValue) {
|
||||
LineValue.OLD_YIN -> of(CoinSide.CHARACTER, CoinSide.CHARACTER, CoinSide.CHARACTER)
|
||||
LineValue.YOUNG_YANG -> of(CoinSide.CHARACTER, CoinSide.CHARACTER, CoinSide.REVERSE)
|
||||
LineValue.YOUNG_YIN -> of(CoinSide.CHARACTER, CoinSide.REVERSE, CoinSide.REVERSE)
|
||||
LineValue.OLD_YANG -> of(CoinSide.REVERSE, CoinSide.REVERSE, CoinSide.REVERSE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class HexagramPattern private constructor(val linesBottomUp: List<Polarity>) {
|
||||
init {
|
||||
require(linesBottomUp.size == LINE_COUNT) {
|
||||
"A hexagram must contain exactly $LINE_COUNT lines, but contained ${linesBottomUp.size}"
|
||||
}
|
||||
}
|
||||
|
||||
fun encoded(): String = linesBottomUp.joinToString(separator = "") {
|
||||
if (it == Polarity.YANG) "1" else "0"
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val LINE_COUNT = 6
|
||||
|
||||
fun of(linesBottomUp: List<Polarity>): HexagramPattern =
|
||||
HexagramPattern(linesBottomUp.toList())
|
||||
|
||||
fun decode(encoded: String): HexagramPattern {
|
||||
require(encoded.length == LINE_COUNT && encoded.all { it == '0' || it == '1' }) {
|
||||
"Encoded hexagram must contain exactly six 0/1 characters"
|
||||
}
|
||||
return of(encoded.map { if (it == '1') Polarity.YANG else Polarity.YIN })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JvmInline
|
||||
value class HexagramId(val value: Int) {
|
||||
init {
|
||||
require(value in 1..64) { "King Wen hexagram id must be between 1 and 64" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package brainwave.domain.casting
|
||||
|
||||
object HexagramCatalog {
|
||||
private enum class Trigram(val patternBottomUp: String) {
|
||||
QIAN("111"),
|
||||
DUI("110"),
|
||||
LI("101"),
|
||||
ZHEN("100"),
|
||||
XUN("011"),
|
||||
KAN("010"),
|
||||
GEN("001"),
|
||||
KUN("000"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromPattern(patternBottomUp: String): Trigram =
|
||||
entries.singleOrNull { it.patternBottomUp == patternBottomUp }
|
||||
?: error("Unknown trigram pattern: $patternBottomUp")
|
||||
}
|
||||
}
|
||||
|
||||
private data class TrigramPair(val upper: Trigram, val lower: Trigram)
|
||||
|
||||
private val kingWenIds = mapOf(
|
||||
TrigramPair(Trigram.QIAN, Trigram.QIAN) to 1,
|
||||
TrigramPair(Trigram.KUN, Trigram.KUN) to 2,
|
||||
TrigramPair(Trigram.KAN, Trigram.ZHEN) to 3,
|
||||
TrigramPair(Trigram.GEN, Trigram.KAN) to 4,
|
||||
TrigramPair(Trigram.KAN, Trigram.QIAN) to 5,
|
||||
TrigramPair(Trigram.QIAN, Trigram.KAN) to 6,
|
||||
TrigramPair(Trigram.KUN, Trigram.KAN) to 7,
|
||||
TrigramPair(Trigram.KAN, Trigram.KUN) to 8,
|
||||
TrigramPair(Trigram.XUN, Trigram.QIAN) to 9,
|
||||
TrigramPair(Trigram.QIAN, Trigram.DUI) to 10,
|
||||
TrigramPair(Trigram.KUN, Trigram.QIAN) to 11,
|
||||
TrigramPair(Trigram.QIAN, Trigram.KUN) to 12,
|
||||
TrigramPair(Trigram.QIAN, Trigram.LI) to 13,
|
||||
TrigramPair(Trigram.LI, Trigram.QIAN) to 14,
|
||||
TrigramPair(Trigram.KUN, Trigram.GEN) to 15,
|
||||
TrigramPair(Trigram.ZHEN, Trigram.KUN) to 16,
|
||||
TrigramPair(Trigram.DUI, Trigram.ZHEN) to 17,
|
||||
TrigramPair(Trigram.GEN, Trigram.XUN) to 18,
|
||||
TrigramPair(Trigram.KUN, Trigram.DUI) to 19,
|
||||
TrigramPair(Trigram.XUN, Trigram.KUN) to 20,
|
||||
TrigramPair(Trigram.LI, Trigram.ZHEN) to 21,
|
||||
TrigramPair(Trigram.GEN, Trigram.LI) to 22,
|
||||
TrigramPair(Trigram.GEN, Trigram.KUN) to 23,
|
||||
TrigramPair(Trigram.KUN, Trigram.ZHEN) to 24,
|
||||
TrigramPair(Trigram.QIAN, Trigram.ZHEN) to 25,
|
||||
TrigramPair(Trigram.GEN, Trigram.QIAN) to 26,
|
||||
TrigramPair(Trigram.GEN, Trigram.ZHEN) to 27,
|
||||
TrigramPair(Trigram.DUI, Trigram.XUN) to 28,
|
||||
TrigramPair(Trigram.KAN, Trigram.KAN) to 29,
|
||||
TrigramPair(Trigram.LI, Trigram.LI) to 30,
|
||||
TrigramPair(Trigram.DUI, Trigram.GEN) to 31,
|
||||
TrigramPair(Trigram.ZHEN, Trigram.XUN) to 32,
|
||||
TrigramPair(Trigram.QIAN, Trigram.GEN) to 33,
|
||||
TrigramPair(Trigram.ZHEN, Trigram.QIAN) to 34,
|
||||
TrigramPair(Trigram.LI, Trigram.KUN) to 35,
|
||||
TrigramPair(Trigram.KUN, Trigram.LI) to 36,
|
||||
TrigramPair(Trigram.XUN, Trigram.LI) to 37,
|
||||
TrigramPair(Trigram.LI, Trigram.DUI) to 38,
|
||||
TrigramPair(Trigram.KAN, Trigram.GEN) to 39,
|
||||
TrigramPair(Trigram.ZHEN, Trigram.KAN) to 40,
|
||||
TrigramPair(Trigram.GEN, Trigram.DUI) to 41,
|
||||
TrigramPair(Trigram.XUN, Trigram.ZHEN) to 42,
|
||||
TrigramPair(Trigram.DUI, Trigram.QIAN) to 43,
|
||||
TrigramPair(Trigram.QIAN, Trigram.XUN) to 44,
|
||||
TrigramPair(Trigram.DUI, Trigram.KUN) to 45,
|
||||
TrigramPair(Trigram.KUN, Trigram.XUN) to 46,
|
||||
TrigramPair(Trigram.DUI, Trigram.KAN) to 47,
|
||||
TrigramPair(Trigram.KAN, Trigram.XUN) to 48,
|
||||
TrigramPair(Trigram.DUI, Trigram.LI) to 49,
|
||||
TrigramPair(Trigram.LI, Trigram.XUN) to 50,
|
||||
TrigramPair(Trigram.ZHEN, Trigram.ZHEN) to 51,
|
||||
TrigramPair(Trigram.GEN, Trigram.GEN) to 52,
|
||||
TrigramPair(Trigram.XUN, Trigram.GEN) to 53,
|
||||
TrigramPair(Trigram.ZHEN, Trigram.DUI) to 54,
|
||||
TrigramPair(Trigram.ZHEN, Trigram.LI) to 55,
|
||||
TrigramPair(Trigram.LI, Trigram.GEN) to 56,
|
||||
TrigramPair(Trigram.XUN, Trigram.XUN) to 57,
|
||||
TrigramPair(Trigram.DUI, Trigram.DUI) to 58,
|
||||
TrigramPair(Trigram.XUN, Trigram.KAN) to 59,
|
||||
TrigramPair(Trigram.KAN, Trigram.DUI) to 60,
|
||||
TrigramPair(Trigram.XUN, Trigram.DUI) to 61,
|
||||
TrigramPair(Trigram.ZHEN, Trigram.GEN) to 62,
|
||||
TrigramPair(Trigram.KAN, Trigram.LI) to 63,
|
||||
TrigramPair(Trigram.LI, Trigram.KAN) to 64,
|
||||
)
|
||||
|
||||
init {
|
||||
check(kingWenIds.size == 64) { "King Wen table must contain all 64 trigram pairs" }
|
||||
check(kingWenIds.values.toSet() == (1..64).toSet()) {
|
||||
"King Wen table must contain every id from 1 through 64 exactly once"
|
||||
}
|
||||
}
|
||||
|
||||
fun idFor(pattern: HexagramPattern): HexagramId {
|
||||
val encoded = pattern.encoded()
|
||||
val lower = Trigram.fromPattern(encoded.substring(0, 3))
|
||||
val upper = Trigram.fromPattern(encoded.substring(3, 6))
|
||||
return HexagramId(kingWenIds.getValue(TrigramPair(upper, lower)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package brainwave.data.content
|
||||
|
||||
import brainwave.core.model.HexagramContent
|
||||
|
||||
class FakeHexagramContentRepository(
|
||||
override val contentVersion: String = "fixture-only-v1",
|
||||
entries: List<HexagramContent>,
|
||||
) : HexagramContentRepository {
|
||||
private val entriesById: Map<Int, HexagramContent>
|
||||
|
||||
init {
|
||||
require(contentVersion.isNotBlank()) { "Content version must not be blank" }
|
||||
require(entries.map(HexagramContent::kingWenNumber).distinct().size == entries.size) {
|
||||
"Fake content entries must have unique King Wen numbers"
|
||||
}
|
||||
entriesById = entries.associateBy(HexagramContent::kingWenNumber)
|
||||
}
|
||||
|
||||
override fun contentFor(
|
||||
kingWenNumber: Int,
|
||||
requestedContentVersion: String,
|
||||
): HexagramContent {
|
||||
if (requestedContentVersion != contentVersion) {
|
||||
throw ContentIntegrityException(
|
||||
"Requested content version '$requestedContentVersion' is unavailable; loaded '$contentVersion'",
|
||||
)
|
||||
}
|
||||
return entriesById[kingWenNumber]
|
||||
?: throw ContentIntegrityException("No validated content for King Wen number $kingWenNumber")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package brainwave.data.content
|
||||
|
||||
import brainwave.core.model.HexagramContent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
class HexagramContentRepositoryTest {
|
||||
@Test
|
||||
fun `fake returns content only for its exact version and id`() {
|
||||
val qian = fixture(1)
|
||||
val repository = FakeHexagramContentRepository(entries = listOf(qian))
|
||||
|
||||
assertEquals(qian, repository.contentFor(1))
|
||||
assertFails("unavailable") { repository.contentFor(1, "another-version") }
|
||||
assertFails("No validated content") { repository.contentFor(2) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fake rejects duplicate ids instead of silently overwriting`() {
|
||||
assertFails("unique King Wen numbers") {
|
||||
FakeHexagramContentRepository(entries = listOf(fixture(1), fixture(1)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `content model enforces six bottom-up texts and sources`() {
|
||||
assertFails("six non-blank") {
|
||||
fixture(1).copy(lineTextsBottomUp = List(5) { "fixture" })
|
||||
}
|
||||
assertFails("at least one") {
|
||||
fixture(1).copy(sourceRefs = emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
private fun fixture(id: Int) = HexagramContent(
|
||||
kingWenNumber = id,
|
||||
name = "fixture-$id",
|
||||
symbol = "fixture-symbol-$id",
|
||||
judgmentOriginal = "fixture original",
|
||||
judgmentPlain = "fixture plain",
|
||||
lineTextsBottomUp = List(6) { index -> "fixture original line ${index + 1}" },
|
||||
linePlainBottomUp = List(6) { index -> "fixture plain line ${index + 1}" },
|
||||
specialUsageText = null,
|
||||
sourceRefs = listOf("fixture-source"),
|
||||
)
|
||||
|
||||
private fun assertFails(messageFragment: String, block: () -> Unit) {
|
||||
try {
|
||||
block()
|
||||
fail("Expected failure containing '$messageFragment'")
|
||||
} catch (error: IllegalArgumentException) {
|
||||
assertTrue(error.message.orEmpty().contains(messageFragment))
|
||||
} catch (error: ContentIntegrityException) {
|
||||
assertTrue(error.message.orEmpty().contains(messageFragment))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package brainwave.domain.casting
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
class CastEngineTest {
|
||||
@Test
|
||||
fun `all eight coin combinations map to the correct line values`() {
|
||||
val counts = mutableMapOf<LineValue, Int>()
|
||||
|
||||
for (encoded in 0 until 8) {
|
||||
val coins = List(3) { index ->
|
||||
if (encoded and (1 shl index) == 0) CoinSide.CHARACTER else CoinSide.REVERSE
|
||||
}
|
||||
val expected = LineValue.fromScore(coins.sumOf(CoinSide::score))
|
||||
val actual = CastRound.of(coins).lineValue
|
||||
assertEquals(expected, actual)
|
||||
counts[actual] = counts.getOrDefault(actual, 0) + 1
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
mapOf(
|
||||
LineValue.OLD_YIN to 1,
|
||||
LineValue.YOUNG_YANG to 3,
|
||||
LineValue.YOUNG_YIN to 3,
|
||||
LineValue.OLD_YANG to 1,
|
||||
),
|
||||
counts,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `line values expose correct polarity movement and transformation`() {
|
||||
assertLine(LineValue.OLD_YIN, Polarity.YIN, moving = true, Polarity.YANG)
|
||||
assertLine(LineValue.YOUNG_YANG, Polarity.YANG, moving = false, Polarity.YANG)
|
||||
assertLine(LineValue.YOUNG_YIN, Polarity.YIN, moving = false, Polarity.YIN)
|
||||
assertLine(LineValue.OLD_YANG, Polarity.YANG, moving = true, Polarity.YIN)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a round must preserve exactly three coins`() {
|
||||
assertFails("exactly 3 coins") { CastRound.of(emptyList()) }
|
||||
assertFails("exactly 3 coins") {
|
||||
CastRound.of(List(4) { CoinSide.CHARACTER })
|
||||
}
|
||||
assertEquals(
|
||||
listOf(2, 3, 2),
|
||||
CastRound.of(CoinSide.CHARACTER, CoinSide.REVERSE, CoinSide.CHARACTER)
|
||||
.coins
|
||||
.map(CoinSide::score),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cast requires exactly six rounds`() {
|
||||
assertFails("exactly six rounds") {
|
||||
CastEngine.cast(List(5) { roundFor(LineValue.YOUNG_YANG) })
|
||||
}
|
||||
assertFails("exactly six rounds") {
|
||||
CastEngine.cast(List(7) { roundFor(LineValue.YOUNG_YANG) })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `documented fixtures preserve bottom-up order and King Wen ids`() {
|
||||
assertFixture(listOf(7, 7, 7, 7, 7, 7), primary = 1, transformed = 1, moving = emptyList())
|
||||
assertFixture(listOf(8, 8, 8, 8, 8, 8), primary = 2, transformed = 2, moving = emptyList())
|
||||
assertFixture(listOf(9, 9, 9, 9, 9, 9), primary = 1, transformed = 2, moving = (1..6).toList())
|
||||
assertFixture(listOf(6, 6, 6, 6, 6, 6), primary = 2, transformed = 1, moving = (1..6).toList())
|
||||
assertFixture(listOf(7, 8, 8, 8, 8, 8), primary = 24, transformed = 24, moving = emptyList())
|
||||
assertFixture(listOf(9, 8, 8, 8, 8, 8), primary = 24, transformed = 2, moving = listOf(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all 4096 line sequences compute consistently`() {
|
||||
val primaryIds = mutableSetOf<Int>()
|
||||
|
||||
for (encoded in 0 until 4096) {
|
||||
val lineValues = List(6) { position ->
|
||||
LineValue.entries[(encoded shr (position * 2)) and 0b11]
|
||||
}
|
||||
val result = CastEngine.cast(lineValues.map(::roundFor))
|
||||
|
||||
assertEquals(lineValues, result.lineValuesBottomUp)
|
||||
assertEquals(lineValues.map(LineValue::polarity), result.primaryPatternBottomUp.linesBottomUp)
|
||||
assertEquals(
|
||||
lineValues.map(LineValue::transformedPolarity),
|
||||
result.transformedPatternBottomUp.linesBottomUp,
|
||||
)
|
||||
assertEquals(
|
||||
lineValues.mapIndexedNotNull { index, line -> if (line.isMoving) index + 1 else null },
|
||||
result.movingLinePositions,
|
||||
)
|
||||
assertTrue(result.primaryHexagramId.value in 1..64)
|
||||
assertTrue(result.transformedHexagramId.value in 1..64)
|
||||
primaryIds += result.primaryHexagramId.value
|
||||
}
|
||||
|
||||
assertEquals((1..64).toSet(), primaryIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all 64 polarity patterns map one-to-one to King Wen ids`() {
|
||||
val ids = (0 until 64).map { encoded ->
|
||||
val pattern = HexagramPattern.of(
|
||||
List(6) { position ->
|
||||
if (encoded and (1 shl position) == 0) Polarity.YIN else Polarity.YANG
|
||||
},
|
||||
)
|
||||
HexagramCatalog.idFor(pattern).value
|
||||
}
|
||||
|
||||
assertEquals(64, ids.toSet().size)
|
||||
assertEquals((1..64).toSet(), ids.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `record metadata is separate from deterministic computation`() {
|
||||
val rounds = listOf(9, 8, 8, 8, 8, 8).map(::roundFor)
|
||||
val computation = CastEngine.cast(rounds)
|
||||
val first = CastResult.record(computation, CastMetadata("content-test", "2026-01-01T00:00:00Z"))
|
||||
val second = CastResult.record(computation, CastMetadata("content-test", "2026-02-01T00:00:00Z"))
|
||||
|
||||
assertNotEquals(first.createdAt, second.createdAt)
|
||||
assertEquals(first.roundsBottomUp, second.roundsBottomUp)
|
||||
assertEquals(first.lineValuesBottomUp, second.lineValuesBottomUp)
|
||||
assertEquals(first.primaryHexagramId, second.primaryHexagramId)
|
||||
assertEquals(first.transformedHexagramId, second.transformedHexagramId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `versioned dto round-trips all preserved coins and derived values`() {
|
||||
val computation = CastEngine.cast(listOf(6, 7, 8, 9, 7, 8).map(::roundFor))
|
||||
val original = CastResult.record(
|
||||
computation,
|
||||
CastMetadata(contentVersion = "fixture-v1", createdAt = "2026-08-04T12:00:00+08:00"),
|
||||
)
|
||||
|
||||
val restored = CastRecordDto.fromDomain(original).toDomain()
|
||||
|
||||
assertEquals(original, restored)
|
||||
assertEquals(18, restored.roundsBottomUp.sumOf { it.coins.size })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `versioned dto rejects corrupted derived values`() {
|
||||
val original = CastResult.record(
|
||||
CastEngine.cast(List(6) { roundFor(LineValue.YOUNG_YANG) }),
|
||||
CastMetadata(contentVersion = "fixture-v1", createdAt = "2026-08-04T12:00:00+08:00"),
|
||||
)
|
||||
val corrupted = CastRecordDto.fromDomain(original).copy(primaryHexagramId = 2)
|
||||
|
||||
assertFails("does not match") { corrupted.toDomain() }
|
||||
}
|
||||
|
||||
private fun assertFixture(
|
||||
scoresBottomUp: List<Int>,
|
||||
primary: Int,
|
||||
transformed: Int,
|
||||
moving: List<Int>,
|
||||
) {
|
||||
val result = CastEngine.cast(scoresBottomUp.map(::roundFor))
|
||||
assertEquals(primary, result.primaryHexagramId.value)
|
||||
assertEquals(transformed, result.transformedHexagramId.value)
|
||||
assertEquals(moving, result.movingLinePositions)
|
||||
assertEquals(scoresBottomUp, result.lineValuesBottomUp.map(LineValue::score))
|
||||
}
|
||||
|
||||
private fun assertLine(
|
||||
lineValue: LineValue,
|
||||
polarity: Polarity,
|
||||
moving: Boolean,
|
||||
transformed: Polarity,
|
||||
) {
|
||||
assertEquals(polarity, lineValue.polarity)
|
||||
assertEquals(moving, lineValue.isMoving)
|
||||
assertEquals(transformed, lineValue.transformedPolarity)
|
||||
if (moving) assertNotEquals(polarity, transformed) else assertEquals(polarity, transformed)
|
||||
}
|
||||
|
||||
private fun roundFor(score: Int): CastRound = roundFor(LineValue.fromScore(score))
|
||||
|
||||
private fun roundFor(lineValue: LineValue): CastRound = CastRound.fromLineValue(lineValue)
|
||||
|
||||
private fun assertFails(messageFragment: String, block: () -> Unit) {
|
||||
try {
|
||||
block()
|
||||
fail("Expected IllegalArgumentException containing '$messageFragment'")
|
||||
} catch (error: IllegalArgumentException) {
|
||||
assertTrue(
|
||||
"Expected '${error.message}' to contain '$messageFragment'",
|
||||
error.message.orEmpty().contains(messageFragment),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import org.gradle.api.tasks.Exec
|
||||
|
||||
plugins {
|
||||
base
|
||||
}
|
||||
|
||||
fun registerNodeVerificationTask(
|
||||
name: String,
|
||||
description: String,
|
||||
script: String,
|
||||
) = tasks.register<Exec>(name) {
|
||||
group = LifecycleBasePlugin.VERIFICATION_GROUP
|
||||
this.description = description
|
||||
workingDir(rootDir)
|
||||
commandLine("node", script)
|
||||
}
|
||||
|
||||
val verifyDocs = registerNodeVerificationTask(
|
||||
name = "verifyDocs",
|
||||
description = "Checks local Markdown links and required harness documents.",
|
||||
script = "scripts/verify-docs.mjs",
|
||||
)
|
||||
|
||||
val scanSecrets = registerNodeVerificationTask(
|
||||
name = "scanSecrets",
|
||||
description = "Scans repository text files for high-confidence secret patterns.",
|
||||
script = "scripts/scan-secrets.mjs",
|
||||
)
|
||||
|
||||
val verifyDomainBoundaries = registerNodeVerificationTask(
|
||||
name = "verifyDomainBoundaries",
|
||||
description = "Prevents Android, persistence, and network imports in the domain core.",
|
||||
script = "scripts/verify-domain-boundaries.mjs",
|
||||
)
|
||||
|
||||
val verifyPrototype = registerNodeVerificationTask(
|
||||
name = "verifyPrototype",
|
||||
description = "Runs dependency-free syntax checks for the HTML prototype harness.",
|
||||
script = "scripts/verify-prototype.mjs",
|
||||
)
|
||||
|
||||
val verifyContentContract = registerNodeVerificationTask(
|
||||
name = "verifyContentContract",
|
||||
description = "Validates the versioned local-content contract and its negative fixtures.",
|
||||
script = "scripts/verify-content-contract.mjs",
|
||||
)
|
||||
|
||||
val spotlessCheck = registerNodeVerificationTask(
|
||||
name = "spotlessCheck",
|
||||
description = "Runs the dependency-free repository formatting gate.",
|
||||
script = "scripts/verify-format.mjs",
|
||||
)
|
||||
|
||||
val verifyDependencyLicenseManifest = registerNodeVerificationTask(
|
||||
name = "verifyDependencyLicenseManifest",
|
||||
description = "Checks the reviewed dependency-license manifest against pinned build versions.",
|
||||
script = "scripts/verify-dependency-licenses.mjs",
|
||||
)
|
||||
|
||||
tasks.register("verifyLocal") {
|
||||
group = LifecycleBasePlugin.VERIFICATION_GROUP
|
||||
description = "Runs every environment-independent local quality gate."
|
||||
dependsOn(
|
||||
":app:test",
|
||||
":app:verifyDependencyLicenses",
|
||||
verifyDocs,
|
||||
scanSecrets,
|
||||
verifyDomainBoundaries,
|
||||
verifyPrototype,
|
||||
verifyContentContract,
|
||||
spotlessCheck,
|
||||
verifyDependencyLicenseManifest,
|
||||
)
|
||||
}
|
||||
|
||||
tasks.named("check") {
|
||||
dependsOn("verifyLocal")
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"reviewedAt": "2026-08-04",
|
||||
"scopeNote": "Current JVM runtime/test graph and direct build entry points; Android dependencies are not configured yet.",
|
||||
"components": [
|
||||
{
|
||||
"id": "gradle-wrapper",
|
||||
"coordinates": null,
|
||||
"version": "8.2",
|
||||
"scope": "build",
|
||||
"license": "Apache-2.0",
|
||||
"source": "https://github.com/gradle/gradle/blob/v8.2.0/LICENSE"
|
||||
},
|
||||
{
|
||||
"id": "kotlin-gradle-plugin",
|
||||
"coordinates": null,
|
||||
"version": "1.9.20",
|
||||
"scope": "build",
|
||||
"license": "Apache-2.0",
|
||||
"source": "https://github.com/JetBrains/kotlin/blob/v1.9.20/license/LICENSE.txt"
|
||||
},
|
||||
{
|
||||
"id": "kotlin-stdlib",
|
||||
"coordinates": "org.jetbrains.kotlin:kotlin-stdlib:1.9.20",
|
||||
"version": "1.9.20",
|
||||
"scope": "runtime",
|
||||
"license": "Apache-2.0",
|
||||
"source": "https://github.com/JetBrains/kotlin/blob/v1.9.20/license/LICENSE.txt"
|
||||
},
|
||||
{
|
||||
"id": "jetbrains-annotations",
|
||||
"coordinates": "org.jetbrains:annotations:13.0",
|
||||
"version": "13.0",
|
||||
"scope": "runtime-transitive",
|
||||
"license": "Apache-2.0",
|
||||
"source": "https://repo1.maven.org/maven2/org/jetbrains/annotations/13.0/annotations-13.0.pom"
|
||||
},
|
||||
{
|
||||
"id": "junit4",
|
||||
"coordinates": "junit:junit:4.13.2",
|
||||
"version": "4.13.2",
|
||||
"scope": "test",
|
||||
"license": "EPL-1.0",
|
||||
"source": "https://github.com/junit-team/junit4/blob/r4.13.2/LICENSE-junit.txt"
|
||||
},
|
||||
{
|
||||
"id": "hamcrest-core",
|
||||
"coordinates": "org.hamcrest:hamcrest-core:1.3",
|
||||
"version": "1.3",
|
||||
"scope": "test-transitive",
|
||||
"license": "BSD-3-Clause",
|
||||
"source": "https://github.com/hamcrest/JavaHamcrest/blob/hamcrest-java-1.3/LICENSE.txt"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://brainwave.invalid/schema/hexagram-content-v1.json",
|
||||
"title": "Brainwave local hexagram content package",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"contentVersion",
|
||||
"specialUsageTexts",
|
||||
"sources",
|
||||
"hexagrams"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": { "const": 1 },
|
||||
"contentVersion": { "type": "string", "minLength": 1 },
|
||||
"specialUsageTexts": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["qian", "kun"],
|
||||
"properties": {
|
||||
"qian": { "type": "boolean" },
|
||||
"kun": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/$defs/source" }
|
||||
},
|
||||
"hexagrams": {
|
||||
"type": "array",
|
||||
"minItems": 64,
|
||||
"maxItems": 64,
|
||||
"items": { "$ref": "#/$defs/hexagram" }
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"nonBlankText": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
},
|
||||
"trigram": {
|
||||
"enum": ["QIAN", "DUI", "LI", "ZHEN", "XUN", "KAN", "GEN", "KUN"]
|
||||
},
|
||||
"polarity": {
|
||||
"enum": ["YIN", "YANG"]
|
||||
},
|
||||
"source": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "title", "edition", "license", "url"],
|
||||
"properties": {
|
||||
"id": { "$ref": "#/$defs/nonBlankText" },
|
||||
"title": { "$ref": "#/$defs/nonBlankText" },
|
||||
"edition": { "$ref": "#/$defs/nonBlankText" },
|
||||
"license": { "$ref": "#/$defs/nonBlankText" },
|
||||
"url": { "type": "string", "format": "uri" }
|
||||
}
|
||||
},
|
||||
"sixPolarities": {
|
||||
"type": "array",
|
||||
"minItems": 6,
|
||||
"maxItems": 6,
|
||||
"items": { "$ref": "#/$defs/polarity" }
|
||||
},
|
||||
"sixTexts": {
|
||||
"type": "array",
|
||||
"minItems": 6,
|
||||
"maxItems": 6,
|
||||
"items": { "$ref": "#/$defs/nonBlankText" }
|
||||
},
|
||||
"hexagram": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"kingWenNumber",
|
||||
"name",
|
||||
"symbol",
|
||||
"lowerTrigram",
|
||||
"upperTrigram",
|
||||
"patternBottomUp",
|
||||
"judgmentOriginal",
|
||||
"judgmentPlain",
|
||||
"lineTextsBottomUp",
|
||||
"linePlainBottomUp",
|
||||
"specialUsageText",
|
||||
"sourceRefs"
|
||||
],
|
||||
"properties": {
|
||||
"kingWenNumber": { "type": "integer", "minimum": 1, "maximum": 64 },
|
||||
"name": { "$ref": "#/$defs/nonBlankText" },
|
||||
"symbol": { "$ref": "#/$defs/nonBlankText" },
|
||||
"lowerTrigram": { "$ref": "#/$defs/trigram" },
|
||||
"upperTrigram": { "$ref": "#/$defs/trigram" },
|
||||
"patternBottomUp": { "$ref": "#/$defs/sixPolarities" },
|
||||
"judgmentOriginal": { "$ref": "#/$defs/nonBlankText" },
|
||||
"judgmentPlain": { "$ref": "#/$defs/nonBlankText" },
|
||||
"lineTextsBottomUp": { "$ref": "#/$defs/sixTexts" },
|
||||
"linePlainBottomUp": { "$ref": "#/$defs/sixTexts" },
|
||||
"specialUsageText": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/nonBlankText" },
|
||||
{ "type": "null" }
|
||||
]
|
||||
},
|
||||
"sourceRefs": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": { "$ref": "#/$defs/nonBlankText" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 文档状态:方案基线
|
||||
> 最后核验:2026-08-04
|
||||
> 当前阶段:P-1 高保真交互原型已产出、待视觉确认;Android 工程尚未初始化
|
||||
> 当前阶段:P-1 v0.3 待视觉复核;P1 纯 Kotlin 领域核心已完成;P0 仓库门禁与 P2 内容契约已部分完成;可发布 Android 壳仍等待正式身份与 `minSdk` 决策
|
||||
|
||||
本目录是 Brainwave 的项目知识事实源。产品决策、领域算法、架构边界、验收标准和已知失败模式必须写入仓库;聊天记录、口头约定和临时提示不构成项目规范。
|
||||
|
||||
@@ -24,6 +24,7 @@ Brainwave 是一个以《易经》三枚铜币法为文化背景的 Android 个
|
||||
| [数据与内容](data-content.md) | 修改卦库、历史记录或内容来源时 | 数据契约、授权、隐私和迁移规则 |
|
||||
| [AI 解释与安全](ai-safety.md) | 修改提示词、模型调用或解释结果时 | AI 调用门、输入输出契约和安全边界 |
|
||||
| [质量门禁](quality-gates.md) | 实现、评审、发布前 | 自动化验证、需求追踪和完成定义 |
|
||||
| [依赖与许可证](dependency-licenses.md) | 新增/升级依赖或准备分发时 | 当前解析依赖、SPDX 与权威许可来源 |
|
||||
| [实施计划](implementation-plan.md) | 领取任务或判断下一步时 | 阶段、依赖、交付物和退出条件 |
|
||||
| [决策记录](decisions.md) | 遇到架构分歧或未决问题时 | 已接受决定、默认假设和 TBD |
|
||||
| [代理工作手册](agent-playbook.md) | 任何编码代理开始工作前 | 检索、修改、验证和交付流程 |
|
||||
@@ -73,6 +74,8 @@ Brainwave 是一个以《易经》三枚铜币法为文化背景的 Android 个
|
||||
- 用户真实投币并录入;MVP 不提供随机起卦按钮。
|
||||
- 东方文化表达采用“纸、墨、朱砂、留白”的内容优先风格。
|
||||
- AI 仅在用户主动点击「解」之后调用。
|
||||
- 完整起卦、问题、解读与行动记录默认保存在 App 私有本机存储,首页明确告知,用户可在设置全局关闭或对当次退出。
|
||||
- 本地历史默认排除系统/设备迁移/云备份;自动保存与 AI 数据发送授权完全独立。
|
||||
|
||||
仍需产品确认的事项记录在[决策记录](decisions.md#未决问题)。任何代理不得把 `TBD` 悄悄变成产品事实。
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
|
||||
禁止后台预取、自动重试到另一模型、首次进入结果页即调用、以及把 AI 回复用于改写 `CastResult`。
|
||||
|
||||
本地历史自动保存、读取问卦簿、切换保存设置或已有 AI 解读记录,都不构成网络调用授权。`autoSaveHistory` 与 AI 同意版本必须是独立状态;任何保存路径均不得顺带调用 AI。
|
||||
|
||||
## 2. AI 输入契约
|
||||
|
||||
客户端提交给后端的业务载荷应是明确 DTO,不发送整个数据库实体或 UI 状态:
|
||||
@@ -143,6 +145,7 @@ Android 端密钥管理参考:[Android 安全清单](https://developer.android
|
||||
|
||||
- 同一 `CastResult` 在 AI 请求前后字节级确定字段不变。
|
||||
- 没有 `CastResult`、未点击、未同意或结果尚未显示时,网络 mock 收到 0 次调用。
|
||||
- 自动保存完成、历史详情打开、保存设置切换和“保存本次”执行时,网络 mock 均收到 0 次调用;这些操作不得改变 AI 同意状态。
|
||||
- 提示注入样例不能让模型重算卦、泄露系统提示或执行用户文本中的指令。
|
||||
- 高风险测试集不产生确定的医疗、法律、投资或人生决定。
|
||||
- 所有合法输出都有可撤销行动;所有非法输出都被客户端拒绝且可改用本地解释。
|
||||
|
||||
@@ -58,6 +58,7 @@ app/src/main/java/<package>/
|
||||
│ └── ai/ # 网络 DTO、gateway、响应校验
|
||||
└── feature/
|
||||
├── onboarding/
|
||||
├── home/ # 回访首页、最近一次、已上线次级入口
|
||||
├── question/
|
||||
├── casting/
|
||||
├── result/
|
||||
@@ -95,7 +96,7 @@ core/designsystem → Compose/Material + core model(仅绘制需要)
|
||||
|
||||
### `CastEngine`
|
||||
|
||||
纯 Kotlin、无副作用。输入六轮铜币和方法版本,输出不可变 `CastResult`。所有规则来自[领域规则](domain-rules.md)。
|
||||
纯 Kotlin、无副作用。输入六轮铜币,使用 API 固定的 `coin-v1` 约定输出不可变 `CastComputation`;随后由记录工厂附加 `contentVersion` 与 `createdAt`,组成不可变 `CastResult`。方法版本不是调用方可随意传入的自由字符串,记录元数据也不能进入计算。所有规则来自[领域规则](domain-rules.md)。
|
||||
|
||||
### `HexagramContentRepository`
|
||||
|
||||
@@ -107,7 +108,13 @@ core/designsystem → Compose/Material + core model(仅绘制需要)
|
||||
|
||||
### `HistoryRepository`
|
||||
|
||||
在用户明确保存后持久化会话。Room 模型不得泄露到 UI;数据库迁移必须有测试。
|
||||
接收第六爻锁定事件与当次 `HistorySavePolicy` 快照,在 `autoSaveHistory=true` 时持久化会话;总开关关闭时仍支持结果页明确发出的“保存本次”命令。策略包含 `saveQuestion`、`saveExplanation` 和 `saveActionNote`,生产默认值均为 `true`,由 DataStore 提供但不得泄露到领域起卦逻辑。
|
||||
|
||||
Repository 提供倒序历史与最新一条的 `Flow` 查询,并把随后产生的本地或 AI 解读事务性关联到同一 session。重新解读新增版本或执行经过明确确认的替换命令,禁止普通 upsert 静默覆盖。DAO 事务负责 session、explanation 和 action 的一致写入与级联删除;首页直接订阅最新记录的投影,不维护第二份敏感正文缓存。Room 模型不得泄露到 UI;数据库迁移必须有测试。
|
||||
|
||||
### `HomeViewModel` / `HistoryViewModel`
|
||||
|
||||
`HomeViewModel` 组合 onboarding 状态、最新历史投影、保存策略摘要和已启用功能入口,只输出真实可进入的入口;不为未来功能生成禁用占位。首页固定输出本机保存/AI 发送边界文案和设置入口。`HistoryViewModel` 维护列表、空态、详情、单条删除和清空全部确认状态,通过 `HistoryRepository` 完成操作。导航使用一次性 UI 事件或 NavHost 回调,返回后恢复列表位置。
|
||||
|
||||
### `ExplanationRepository`
|
||||
|
||||
@@ -136,10 +143,12 @@ ResultUiState(
|
||||
## 7. 数据和存储选择
|
||||
|
||||
- **assets 或预置 Room**:版本化的 64 卦及爻辞。首版如果仅按编号读取,JSON assets 更简单;需要全文检索或内容迁移时再采用预置 Room。
|
||||
- **Room**:用户主动保存的会话、解释和行动记录。Android 官方推荐 Room 管理非平凡结构化数据。[Room 文档](https://developer.android.com/training/data-storage/room)
|
||||
- **DataStore**:方法说明是否已读、主题、AI 同意版本等少量设置;不保存大型历史或完整卦库。[DataStore 文档](https://developer.android.com/topic/libraries/architecture/datastore)
|
||||
- **Room**:按保存策略自动写入或由用户当次明确保存的会话、解释和行动记录。Android 官方推荐 Room 管理非平凡结构化数据。[Room 文档](https://developer.android.com/training/data-storage/room)
|
||||
- **DataStore**:方法说明是否已读、主题、AI 同意版本和历史保存策略等少量设置;不保存大型历史或完整卦库。[DataStore 文档](https://developer.android.com/topic/libraries/architecture/datastore)
|
||||
- **SavedStateHandle**:恢复当前未完成流程;不是长期历史数据库。
|
||||
|
||||
历史 Room 数据库及其辅助文件必须通过 Android backup/data-extraction 配置排除系统自动备份和设备迁移。此边界由构建配置测试机械验证,不能只依赖界面文案;引入导出、账号或同步前必须新增 ADR。
|
||||
|
||||
详细契约见[数据与内容](data-content.md)。
|
||||
|
||||
## 8. 网络与 AI 边界
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
|
||||
| 数据 | 来源 | 默认位置 | 敏感性 | 是否参与起卦 |
|
||||
|---|---|---|---|---|
|
||||
| 三轮币面/六爻结果 | 用户输入/本地计算 | 会话状态,可选 Room | 低至中 | 是 |
|
||||
| 用户问题 | 用户输入 | 会话状态,可选 Room | 高 | 否 |
|
||||
| 十八枚币面/六爻结果 | 用户输入/本地计算 | 会话状态;结果锁定后默认 Room | 低至中 | 是 |
|
||||
| 用户问题 | 用户输入 | 会话状态;按默认开启的设置进入 Room | 高 | 否 |
|
||||
| 64 卦与爻辞 | 经审核的内容包 | assets 或预置 Room | 低,关注版权 | 仅用于查表 |
|
||||
| 本地解释 | 编辑内容 | assets | 低,关注版权 | 否 |
|
||||
| AI 请求与回复 | 用户主动请求 | 内存,可选 Room | 高 | 否 |
|
||||
| AI 请求与回复 | 用户主动请求 | 请求在内存;回复按默认开启的设置进入 Room | 高 | 否 |
|
||||
| 偏好与同意版本 | 用户设置 | DataStore | 中 | 否 |
|
||||
|
||||
任何“否”的数据都不能成为 `CastEngine` 输入。
|
||||
@@ -24,6 +24,10 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"contentVersion": "zh-Hans-2026.1",
|
||||
"specialUsageTexts": {
|
||||
"qian": true,
|
||||
"kun": true
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"id": "source-id",
|
||||
@@ -54,6 +58,8 @@
|
||||
|
||||
示例中的省略号不是可发布内容。禁止由 AI 在构建时临时补齐缺失卦辞或爻辞。
|
||||
|
||||
机器契约位于 `content/schema/hexagram-content.schema.json`。`specialUsageTexts` 显式声明当前内容版本是否提供乾“用九”和坤“用六”;声明为 `false` 时对应条目的 `specialUsageText` 必须为 `null`,不能用空字符串暗示内容存在。Android parser 尚未建立前,`scripts/verify-content-contract.mjs` 已提供独立构建期校验、稳定 SHA-256 摘要和不含可发布卦辞的自动化夹具;`HexagramContentRepository` 接口与测试 fake 已建立,缺少 ID 或内容版本不匹配时抛出数据完整性错误,不回退到相邻条目。
|
||||
|
||||
## 3. 内容完整性门禁
|
||||
|
||||
内容包进入应用前必须自动验证:
|
||||
@@ -106,6 +112,7 @@ ExplanationEntity
|
||||
- contractVersion: string
|
||||
- content: validated structured JSON
|
||||
- createdAt
|
||||
- supersedesExplanationId: nullable foreign key
|
||||
|
||||
ActionNoteEntity
|
||||
- sessionId: foreign key
|
||||
@@ -118,6 +125,9 @@ ActionNoteEntity
|
||||
|
||||
- 数据库存储原始 18 枚币面和确定结果,读取时可重新计算并进行一致性检查。
|
||||
- `questionText` 默认可为空;用户不保存问题时不能偷偷复制到其他表。
|
||||
- 第六爻锁定时读取一次 `HistorySavePolicy` 快照;总开关开启时,在单个事务中创建 `CastingSessionEntity`,并按默认开启的内容开关写入 `questionText`。关闭总开关或选择“本次不保存”时不得产生长期记录。
|
||||
- 同一流程随后生成的本地或 AI 解读按策略写入关联的 `ExplanationEntity`;重新解读新增实体并通过 `supersedesExplanationId` 保留版本关系,不得静默覆写旧正文。
|
||||
- 行动记录按策略关联到同一 session;没有实际行动内容时不创建空 `ActionNoteEntity`。
|
||||
- 删除 session 应级联删除解释和行动记录。
|
||||
- enum 使用稳定字符串或显式转换,不依赖 Kotlin ordinal。
|
||||
- 每次 schema 迁移必须有 migration test;禁止发布构建使用 destructive migration。
|
||||
@@ -131,7 +141,12 @@ DataStore 仅保存少量偏好:
|
||||
- 减少应用内非必要动画;
|
||||
- AI 同意文本版本和同意时间;
|
||||
- 默认解释方式;
|
||||
- 是否允许保存问题原文。
|
||||
- `autoSaveHistory`:默认 `true`;
|
||||
- `saveQuestionText`:默认 `true`;
|
||||
- `saveExplanationContent`:默认 `true`;
|
||||
- `saveActionNote`:默认 `true`。
|
||||
|
||||
总开关关闭时,后三项不参与自动写入,但保留用户上次选择;再次开启后恢复。DataStore 中的本地保存偏好与 AI 同意版本是独立字段,不得相互推导。
|
||||
|
||||
不要把六爻历史、完整内容包或 AI 长文本塞入 DataStore。
|
||||
|
||||
@@ -143,22 +158,26 @@ DataStore 仅保存少量偏好:
|
||||
- AI 请求前展示准确的数据清单和服务方类别。
|
||||
- 日志、分析、崩溃报告、截图测试夹具不得使用真实用户问题。
|
||||
- 调试日志使用固定脱敏样例;发布构建关闭网络 body logging。
|
||||
- 历史导出、云备份和 Android Auto Backup 策略在实现前必须单独决策。
|
||||
- `CastingSessionEntity`、`ExplanationEntity`、`ActionNoteEntity` 所在的 Room 数据库及其 `-wal`、`-shm` 等辅助文件必须通过 Android backup/data-extraction 规则排除,不参与 Auto Backup、设备到设备迁移或厂商云备份。
|
||||
- MVP 不提供历史导出、账号或云同步。未来任何导出、备份或同步都必须新增决策、展示准确范围并取得单独的明确选择。
|
||||
|
||||
## 8. 数据生命周期
|
||||
|
||||
```text
|
||||
草稿问题/未完成投币:SavedStateHandle + 内存
|
||||
↓ 用户完成
|
||||
↓ 第六爻确认并锁定
|
||||
不可变 CastResult:结果页内存
|
||||
↓ 用户明确保存
|
||||
Room 历史快照
|
||||
├─ autoSaveHistory=true → Room 会话快照 + 按设置保存问题
|
||||
└─ autoSaveHistory=false → 不写 Room,可由用户选择“保存本次”
|
||||
|
||||
AI 请求:请求期间内存 → 服务端按已披露策略处理
|
||||
AI 回复:结果页内存 → 用户保存后可进入 Room
|
||||
本地/AI 回复:结果页内存 → 按设置关联到同一 Room 会话
|
||||
└─ 会话不存在或解释保存关闭 → 不长期保存,可由用户选择保存本次
|
||||
```
|
||||
|
||||
会话中止后是否保留草稿由产品设置决定;无论如何都不能把草稿作为遥测发送。
|
||||
“最近一次”不是独立复制的数据,而是按 `createdAt` 从 Room 查询得到的最新已保存会话。首页不得缓存另一份问题或解释正文;删除该会话后,最近记录区域必须随查询结果一起更新为空态或下一条记录。
|
||||
|
||||
中止的未完成草稿只按 SavedState 恢复策略存在,不进入问卦簿;无论如何都不能把草稿作为遥测发送。用户选择“本次不保存/删除本次记录”后,应删除已经自动创建的 session 及其关联内容,但不改写全局设置。
|
||||
|
||||
## 9. 内容变更规则
|
||||
|
||||
|
||||
@@ -76,11 +76,13 @@
|
||||
|
||||
## ADR-009:历史记录采用明确保存、本机优先
|
||||
|
||||
- 状态:`Proposed`
|
||||
- 状态:`Superseded by ADR-012`
|
||||
- 日期:2026-08-04
|
||||
- 决定:用户完成后明确保存才进入 Room;保存时可不保留问题原文;默认无账号和云同步。
|
||||
- 关联:FR-H-001~006、原 TBD-007、2026-08-04 用户确认
|
||||
- 决定:用户完成后明确点击保存才进入 Room;固定保存可复核的起卦快照,问题原文和当前解释分别使用默认关闭的独立选项;默认无账号和云同步。
|
||||
- 原因:问题可能高度敏感,自动永久保存不是安全默认值。
|
||||
- 后果:历史功能不能成为完成起卦的前置条件。
|
||||
- 后果:首页在存在记录时显示“最近一次 / 问卦簿”次级入口,但“开始一问”仍是唯一主操作;没有记录时提供说明和开始入口,不显示空白列表。历史功能不能成为完成起卦的前置条件。
|
||||
- 取代原因:用户进一步确认“用户写的内容与解读默认完整保存在 App 内”,并要求在首页显式说明、在设置中可关闭;现行规则见 ADR-012。
|
||||
|
||||
## ADR-010:多动爻全部透明展示
|
||||
|
||||
@@ -101,6 +103,34 @@
|
||||
- 后果:原型是体验契约与评审证据,但不是生产领域代码;P1 必须独立实现和穷举验证算法,P3 复用已确认的设计令牌与状态关系。
|
||||
- 复审条件:用户否定当前流程或目标平台发生变化。
|
||||
|
||||
## ADR-012:完整记录默认在本机自动保存,可配置关闭
|
||||
|
||||
- 状态:`Accepted`
|
||||
- 日期:2026-08-04
|
||||
- 关联:FR-H-001~008、取代 ADR-009、解决原 TBD-008、2026-08-04 用户确认
|
||||
- 决定:
|
||||
1. 第六爻确认并锁定 `CastResult` 后,只要“自动保存完整记录”开启,就立即在 Room 创建可复核的会话快照;未完成的问题与投币草稿仍只用于会话恢复,不进入长期历史。
|
||||
2. `autoSaveHistory`、`saveQuestionText`、`saveExplanationContent` 和 `saveActionNote` 默认均为开启。关闭总开关后不再自动写入历史,三个内容开关同步停用;用户仍可在当次结果页选择“保存本次”。
|
||||
3. 当次随后生成的本地或 AI 解读,在对应设置开启时关联到同一会话;重新解读不得静默覆盖已保存内容,必须新增版本或由用户明确确认替换。
|
||||
4. 首页固定显示“起卦与历史默认保存在本机,不主动上传。只有你选择 AI 解读时,本次所需内容才会发送。”,并提供设置入口。结果页提供明确的自动保存状态和“本次不保存/删除本次记录”。
|
||||
5. 问卦簿支持单条删除和清空全部;删除会级联移除会话、解读和行动记录。
|
||||
6. 历史数据库及其问题、解读和行动数据默认排除 Android Auto Backup、设备到设备迁移的数据提取规则和任何云备份;MVP 不提供账号或云同步。未来备份、导出或同步必须另立决策并取得单独、明确的用户选择。
|
||||
7. 本地自动保存与 AI 数据发送授权完全独立。自动保存开启、历史中已有内容或用户曾同意旧请求,都不能触发网络调用;每次 AI 调用仍须满足 AI 调用门。
|
||||
- 原因:完整的起卦、问题、解读与行动记录共同构成可回顾的个人反思日志;默认保存能避免用户遗漏,但必须通过首页透明告知、设置和单次退出能力保留控制权。
|
||||
- 未采用:继续要求每次手动保存会频繁丢失预期记录;默认只保存卦象快照会让问卦簿缺少上下文,削弱回顾价值。
|
||||
- 后果:P4 必须实现保存策略、事务关联、设置、单次退出、删除、迁移和备份排除测试;ADR-012 是生产事实源,决策时尚未同步的 v0.2 原型只能作为旧流程评审材料,现行 v0.3 已完成同步。
|
||||
- 复审触发:引入账号、导出、云同步、系统备份、跨设备迁移或新的隐私/合规要求。
|
||||
|
||||
## ADR-013:仓库门禁采用无外部依赖脚本并由 Gradle 聚合
|
||||
|
||||
- 状态:`Accepted`
|
||||
- 日期:2026-08-04
|
||||
- 关联:解决 TBD-012、P0/P1/P2/P6
|
||||
- 决定:在 Android application 壳建立前,使用仓库内 Node 脚本执行格式、文档链接、高置信 secret scan、domain 依赖边界、原型语法和内容完整性检查;Gradle 8.2 的 `verifyLocal` 聚合这些检查与 JVM 测试,CI 调用同一入口。
|
||||
- 原因:当前系统已实测 Node 22、JDK 17 和 Gradle 8.2,且无需新增全局工具或把个人路径写进工程;门禁错误能够给出可修复的文件位置。
|
||||
- 后果:`spotlessCheck` 当前是仓库内的无依赖格式兼容入口,不表示已引入 Spotless 插件。Android 壳建立后必须把 lint/debug build 加入 `verifyLocal`;将来若采用维护良好的专用插件,应保留命令兼容或同步更新 CI 与文档。
|
||||
- 复审触发:Android 工具链启用、现有脚本无法表达新边界,或专用工具能以可接受成本提供明显更强的检查。
|
||||
|
||||
## 未决问题
|
||||
|
||||
| ID | 问题 | 推荐默认 | 阻塞阶段 |
|
||||
@@ -111,12 +141,9 @@
|
||||
| TBD-004 | 问题是否允许留空 | 允许选择“不写具体内容”,但需显式操作 | P3 |
|
||||
| TBD-005 | 经典原文、现代白话的版本与授权 | 自有白话 + 可核验公版原文 | P2,发布阻塞 |
|
||||
| TBD-006 | 乾用九、坤用六是否纳入 MVP | 内容具备时展示 | P2/P3 |
|
||||
| TBD-007 | 历史是否默认保存卦象 | 默认不保存,完成后询问 | P4 |
|
||||
| TBD-008 | Android Auto Backup 是否包含历史 | 默认排除敏感历史,待隐私评审 | P4 |
|
||||
| TBD-009 | AI 模型供应商与自有后端 | 供应商无关接口;先交付本地版 | P5 |
|
||||
| TBD-010 | 服务端问题/回复保留期 | 最小化且明确披露,优先不持久化正文 | P5,发布阻塞 |
|
||||
| TBD-011 | 高风险本地资源表覆盖地区 | 首发市场确认后维护,不让模型编号码 | P5 |
|
||||
| TBD-012 | 架构检查工具 | 选择维护活跃工具或小型自定义测试 | P0/P1 |
|
||||
|
||||
## 新增决策模板
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# 依赖与许可证清单
|
||||
|
||||
> 状态:当前 JVM harness 已核验;Android application 依赖尚未配置
|
||||
> 适用范围:实际解析的 JVM runtime/test graph,以及直接使用的构建入口
|
||||
|
||||
本清单不是未来 Android APK 的最终 third-party notices。每次新增或升级依赖时,必须同步 `config/dependency-licenses.json` 并运行 `.\gradlew.bat verifyLocal --offline`;Android 依赖启用后,还要生成 release runtime 的完整报告并复核是否需要在应用或分发包中附带许可证文本。
|
||||
|
||||
| ID | 版本 | 当前范围 | SPDX | 权威许可来源 |
|
||||
|---|---:|---|---|---|
|
||||
| `gradle-wrapper` | 8.2 | 构建入口 | Apache-2.0 | [Gradle 8.2 license](https://github.com/gradle/gradle/blob/v8.2.0/LICENSE) |
|
||||
| `kotlin-gradle-plugin` | 1.9.20 | 构建插件 | Apache-2.0 | [Kotlin 1.9.20 license](https://github.com/JetBrains/kotlin/blob/v1.9.20/license/LICENSE.txt) |
|
||||
| `kotlin-stdlib` | 1.9.20 | JVM runtime | Apache-2.0 | [Kotlin 1.9.20 license](https://github.com/JetBrains/kotlin/blob/v1.9.20/license/LICENSE.txt) |
|
||||
| `jetbrains-annotations` | 13.0 | JVM runtime transitive | Apache-2.0 | [Maven artifact metadata](https://repo1.maven.org/maven2/org/jetbrains/annotations/13.0/annotations-13.0.pom) |
|
||||
| `junit4` | 4.13.2 | test | EPL-1.0 | [JUnit 4.13.2 license](https://github.com/junit-team/junit4/blob/r4.13.2/LICENSE-junit.txt) |
|
||||
| `hamcrest-core` | 1.3 | test transitive | BSD-3-Clause | [Hamcrest 1.3 license](https://github.com/hamcrest/JavaHamcrest/blob/hamcrest-java-1.3/LICENSE.txt) |
|
||||
|
||||
当前没有第三方 Android runtime 库、字体、纹理、插画、音效或可发布《易经》内容进入工程;这句话只描述本提交时的依赖图,不构成未来授权。
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
本文件定义本项目唯一允许的计算规则。UI 文案、AI 输出和数据源都不能覆盖这些规则。
|
||||
|
||||
实现状态:`coin-v1` 纯 Kotlin 核心位于 `app/src/main/java/brainwave/domain/casting/`;`.\gradlew.bat :app:test --offline` 覆盖 8 种币面、4,096 种六爻、64 模式、已知夹具和 DTO 往返。记录时间与内容版本在确定性计算完成后附加。
|
||||
|
||||
## 1. 术语和类型
|
||||
|
||||
建议使用有语义的封闭类型,避免裸 `Int` 在层间传播:
|
||||
@@ -102,7 +104,7 @@ createdAt // 只用于记录,不参与计算
|
||||
3. 从各爻阴阳形成本卦模式并查得本卦编号。
|
||||
4. 收集值为 6 或 9 的位置作为动爻。
|
||||
5. 仅翻转动爻阴阳,形成之卦模式并查得之卦编号。
|
||||
6. 将全部原始输入、版本和确定结果构造成不可变 `CastResult`。
|
||||
6. 先将全部原始输入和确定结果构造成不可变 `CastComputation`,再附加 `methodVersion`、`coinConvention`、`contentVersion` 与 `createdAt` 记录元数据,组成不可变 `CastResult`;元数据不得反向影响步骤 1~5。
|
||||
|
||||
## 6. 读取内容的产品规则
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
| Node.js / npm | `22.22.1` / `11.12.1` |
|
||||
| Google Chrome | `150.0.7871.188`,`C:\Program Files\Google\Chrome\Application\chrome.exe` |
|
||||
|
||||
环境文件写入前,仓库没有 Gradle Wrapper、`build.gradle*`、`settings.gradle*` 或根 `AGENTS.md`。这些属于 Android 工程初始化阶段的交付物,不应被误认为已存在。
|
||||
首次环境审计时仓库没有 Gradle Wrapper、`build.gradle*`、`settings.gradle*` 或根 `AGENTS.md`。当前这些仓库级入口已经建立;`app` 暂时是纯 Kotlin/JVM 领域 harness,不是可安装 Android application。
|
||||
|
||||
Windows 路径可能较长;如果后续依赖缓存或生成代码触发路径长度错误,应优先缩短包/生成目录或评估仓库级长路径配置,并把实际决定写入[决策记录](decisions.md)。
|
||||
|
||||
@@ -101,15 +101,17 @@ Android Studio 不是当前环境的可用前提。项目必须先支持 PowerSh
|
||||
| 已验证 Gradle | `8.2` |
|
||||
| Gradle 使用的 JVM | Temurin `17.0.13` |
|
||||
| 缓存的 Android Gradle Plugin | `8.2.0` |
|
||||
| 缓存的 Kotlin Gradle Plugin | `1.9.20` |
|
||||
| 缓存的 JUnit | `4.13.2` |
|
||||
| 用户级 `~/.gradle/gradle.properties` | 不存在 |
|
||||
|
||||
项目初始化应提交 `gradlew`、`gradlew.bat`、`gradle/wrapper/gradle-wrapper.jar` 和版本明确的 `gradle-wrapper.properties`。所有项目命令使用:
|
||||
仓库已提交 `gradlew`、`gradlew.bat`、`gradle/wrapper/gradle-wrapper.jar` 和固定 Gradle 8.2 及 SHA-256 的 `gradle-wrapper.properties`。所有项目命令使用:
|
||||
|
||||
```powershell
|
||||
.\gradlew.bat <task>
|
||||
```
|
||||
|
||||
不要要求用户安装全局 Gradle。当前缓存能证明 Gradle 8.2 本体可运行,但不能证明 Compose、Kotlin、Hilt、Room 等全部 Maven 依赖已离线缓存;第一次构建仍需实际验证。
|
||||
不要要求用户安装全局 Gradle。当前缓存已实际证明 Gradle 8.2、Kotlin 1.9.20 与 JUnit 4.13.2 可离线完成领域构建和测试;Compose、Hilt、Room 等 Android 依赖仍未配置或验证,不能据此推断可离线解析。
|
||||
|
||||
## 7. 已连接 Android 真机
|
||||
|
||||
@@ -151,13 +153,14 @@ Android Studio 不是当前环境的可用前提。项目必须先支持 PowerSh
|
||||
|
||||
## 10. 已知缺口
|
||||
|
||||
- Android 工程和 Gradle Wrapper 尚未创建。
|
||||
- 可安装的 Android application 壳尚未创建;正式名称、组织所有的 application ID 与最终 `minSdk` 仍待确认。
|
||||
- Android Studio 未安装。
|
||||
- Android Emulator、system image 和 AVD 不可用。
|
||||
- 本机只确认安装了 Android Platform 34 / Build Tools 34.0.0。
|
||||
- Maven 依赖能否完整离线解析尚未验证。
|
||||
- Compose、Hilt、Room、DataStore 与 Navigation 的 Maven 依赖尚未完整离线验证。
|
||||
- 代理已配置,但外部仓库和 Android CLI 网络连通性尚未验证。
|
||||
- 没有根 `AGENTS.md` 和可执行的 `verifyLocal` 聚合任务。
|
||||
|
||||
已解除的缺口:Gradle Wrapper、Version Catalog、根 `AGENTS.md`、CI 与可执行的 `verifyLocal` 已建立;`verifyLocal --offline` 已在 JDK 17 上通过。它当前不包含 Android lint、APK 构建或设备测试。
|
||||
|
||||
这些缺口分别由[实施计划](implementation-plan.md)的 P0 和[质量门禁](quality-gates.md)处理。环境缺口不是跳过验证的理由;无法运行的门禁必须在交付报告中准确说明。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 分阶段实施计划
|
||||
|
||||
> 状态:P-1 高保真交互原型已完成,待用户视觉确认;P0 尚未开始
|
||||
> 状态:P-1 v0.3 待视觉复核;P0 仓库门禁已建立但 Android 壳受 TBD-001~003 阻塞;P1 已完成并通过穷举测试;P2 内容契约已建立但授权内容未开始
|
||||
> 计划原则:先用原型确认高返工成本体验,再锁定确定性领域核心,随后接内容和 UI,最后接网络 AI
|
||||
|
||||
## 1. 依赖图
|
||||
@@ -25,7 +25,8 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
|
||||
任务:
|
||||
|
||||
- 建立 360 × 792 基准视口的高保真 HTML 原型和设计令牌。
|
||||
- 覆盖欢迎、起念、六轮录入、结果、本地/AI 选择、同意、成功与失败状态。
|
||||
- 覆盖欢迎、回访首页、起念、六轮录入、结果、本地/AI 选择、同意、成功与失败状态。
|
||||
- 覆盖默认本机自动保存、首页告知、设置开关、当次退出、解读关联、清空全部、问卦簿列表/空态/详情和删除确认,并保持首页单一主操作。
|
||||
- 使用已知夹具核对本卦、之卦、动爻和无动爻布局。
|
||||
- 导出关键状态截图,并把评审入口与 Android 映射写入[原型说明](prototype.md)。
|
||||
- 收集用户对视觉、录入控件、结果层级与解释语气的明确反馈。
|
||||
@@ -37,7 +38,7 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
|
||||
- 原型不发起外部请求,不伪装真实 AI,不把工作名当作正式决定。
|
||||
- 用户确认或提出一轮可执行的修改意见;确认前不把视觉固化为生产 Compose 页面。
|
||||
|
||||
当前交付物已完成,视觉确认仍待用户评审。
|
||||
核心 v0.1 已由用户确认;v0.3 已同步 ADR-012 并通过十八状态浏览器审计,首页、问卦簿和保存设置待用户视觉复核。
|
||||
|
||||
## 3. P0:仓库与 Android 骨架
|
||||
|
||||
@@ -45,14 +46,14 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
|
||||
|
||||
任务:
|
||||
|
||||
- 读取并遵守[本地开发环境](environment.md):JDK 17、SDK 34、命令行优先、Gradle Wrapper、真机验证。
|
||||
- 基于 Google `android/architecture-templates` 的 `base` 分支初始化。
|
||||
- 确认正式应用名称、package/application ID、minSdk。
|
||||
- 配置 Kotlin、Compose、Material 3、Hilt、Room、DataStore、Navigation 和 Version Catalog。
|
||||
- 配置 Gradle Wrapper、格式化、lint、单元测试和 CI。
|
||||
- 建立 [系统架构](architecture.md)中的包结构和空 feature 边界。
|
||||
- 将根 `AGENTS.md` 设计为短地图,指向本目录和验证命令。
|
||||
- 增加 `verifyLocal` 聚合任务及基础 secret scan。
|
||||
- [x] 读取并遵守[本地开发环境](environment.md):JDK 17、SDK 34、命令行优先、Gradle Wrapper、真机验证。
|
||||
- [ ] 基于 Google `android/architecture-templates` 的 `base` 分支初始化 Android 壳;模板定制需要 TBD-002 的正式 application ID,不能用临时发布身份替代。
|
||||
- [ ] 确认正式应用名称、package/application ID、minSdk(TBD-001~003)。
|
||||
- [ ] 配置 Compose、Material 3、Hilt、Room、DataStore 和 Navigation;Version Catalog 已先用于 JVM harness。
|
||||
- [ ] 配置 Gradle Wrapper、格式化、lint、单元测试和 CI:Wrapper、无依赖格式门禁、JVM 单测与 CI 已完成;Android lint 要等 application 插件启用。
|
||||
- [ ] 建立 [系统架构](architecture.md)中的包结构和空 feature 边界:`domain/casting` 已落地,其余随 Android 壳建立。
|
||||
- [x] 将根 `AGENTS.md` 设计为短地图,指向本目录和验证命令。
|
||||
- [x] 增加 `verifyLocal` 聚合任务、文档链接、领域边界、内容契约与基础 secret scan。
|
||||
|
||||
退出条件:
|
||||
|
||||
@@ -67,13 +68,13 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
|
||||
|
||||
任务:
|
||||
|
||||
- 建立 `CoinSide`、`LineValue`、`Polarity`、`CastRound`、`CastResult`。
|
||||
- 实现六轮输入校验和 `CastEngine`。
|
||||
- 建立经过双重校验的 64 卦模式映射表。
|
||||
- 实现之卦变换和动爻位置。
|
||||
- 实现版本化序列化 DTO。
|
||||
- 完成 8 种币面、4,096 种六爻、64 模式和已知夹具测试。
|
||||
- 增加 domain 无 Android/网络依赖的架构门禁。
|
||||
- [x] 建立 `CoinSide`、`LineValue`、`Polarity`、`CastRound`、`CastResult`。
|
||||
- [x] 实现六轮输入校验和纯 `CastEngine`;记录时间与内容版本在计算后附加。
|
||||
- [x] 建立带完整性自检的 64 卦文王序号映射表。
|
||||
- [x] 实现之卦变换和 1~6 的 bottom-up 动爻位置。
|
||||
- [x] 实现 `schemaVersion=1` 的序列化 DTO,并在读取时用保存的十八枚币重新计算校验派生值。
|
||||
- [x] 完成 8 种币面、4,096 种六爻、64 模式和已知夹具测试。
|
||||
- [x] 增加 domain 无 Android、数据库和网络依赖的机械架构门禁。
|
||||
|
||||
退出条件:
|
||||
|
||||
@@ -81,18 +82,20 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
|
||||
- 测试无随机、无网络、无系统时间依赖。
|
||||
- `CastEngine` API 经评审后冻结为 `coin-v1`。
|
||||
|
||||
当前证据:`.\gradlew.bat :app:test --offline` 中 `CastEngineTest` 执行 10 个测试、0 失败;实现位于 `app/src/main/java/brainwave/domain/casting/`。`brainwave` 是内部代码命名空间,不是 TBD-002 的 application ID。
|
||||
|
||||
## 5. P2:内容数据管线
|
||||
|
||||
目标:建立可追踪、可校验、可发布的本地内容包。
|
||||
|
||||
任务:
|
||||
|
||||
- 决定原文版本、现代白话来源和授权。
|
||||
- 实现 JSON schema、解析器和内容版本。
|
||||
- 录入/导入 64 卦、卦辞、384 条爻辞及所需特殊文本。
|
||||
- 建立来源清单、许可证清单和内容审核记录。
|
||||
- 实现构建期完整性校验与映射交叉校验。
|
||||
- 实现 `HexagramContentRepository` fake 与 assets 版本。
|
||||
- [ ] 决定原文版本、现代白话来源和授权(TBD-005,发布阻塞)。
|
||||
- [ ] 实现 JSON schema、解析器和内容版本:`schemaVersion=1` 的机器 schema 已完成;Android/Kotlin assets 解析器待 Android 壳建立。
|
||||
- [ ] 录入/导入 64 卦、卦辞、384 条爻辞及所需特殊文本。
|
||||
- [ ] 建立来源清单、许可证清单和内容审核记录;schema 已强制每个来源包含版本、许可证与 URL。
|
||||
- [x] 实现构建期完整性校验、文王序号/上下卦/bottom-up 交叉校验、稳定 SHA-256 摘要及 8 个负向夹具。
|
||||
- [ ] 实现 `HexagramContentRepository` fake 与 assets 版本:接口、强校验只读模型和测试 fake 已完成,assets 实现待 Android parser。
|
||||
|
||||
退出条件:
|
||||
|
||||
@@ -129,15 +132,20 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
|
||||
|
||||
- 实现版本化本地解释模板。
|
||||
- 实现“可以试的一小步”的非裁决式结构。
|
||||
- 实现 Room 历史、可选保存问题、详情和删除。
|
||||
- 实现 DataStore 设置与同意版本基础设施。
|
||||
- 完成 migration、删除、隐私和离线测试。
|
||||
- 实现 Room 历史:第六爻锁定后按策略自动保存快照,问题原文、当次解读和行动记录默认保存到同一会话。
|
||||
- 实现 `autoSaveHistory` 总开关及三个默认开启的内容开关;支持总开关关闭后的“保存本次”和全局设置不变的“本次不保存/删除本次记录”。
|
||||
- 实现按时间倒序的问卦簿、派生“最近一次”、空态、详情、解释版本、单条删除和清空全部确认。
|
||||
- 在首页实现固定本机保存/AI 发送边界说明和设置入口;本地保存设置与 AI 同意版本保持独立。
|
||||
- 配置 backup/data-extraction 规则,排除历史 Room 数据库及辅助文件。
|
||||
- 完成 migration、策略组合、事务关联、删除、备份排除、隐私和离线测试。
|
||||
|
||||
退出条件:
|
||||
|
||||
- 用户可选择不保存问题而保存卦象结果。
|
||||
- 默认设置下,完成起卦会写入一条包含问题的 Room 会话,随后生成的解读会关联到同一会话且不静默覆盖旧版本。
|
||||
- 总开关关闭、单个内容开关关闭和“本次不保存”均按契约生效;未完成草稿不进入 Room。
|
||||
- 首页准确告知本机保存与 AI 发送边界,不存在未上线功能的空占位。
|
||||
- 本地解释在无网络时可用。
|
||||
- 删除行为与备份策略一致且经过验证。
|
||||
- 单条删除、清空全部与备份排除策略一致且经过验证。
|
||||
- 此阶段已经是可发布的本地版候选。
|
||||
|
||||
## 8. P5:AI 解读
|
||||
@@ -169,7 +177,7 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
|
||||
|
||||
贯穿所有阶段:
|
||||
|
||||
- 建立 CI、架构检查、内容校验、secret scan 和依赖许可证报告。
|
||||
- [x] 建立 CI、架构检查、内容校验、secret scan 和当前 JVM 依赖许可证报告;Android release 依赖启用后扩展报告。
|
||||
- 建立脱敏崩溃监控和最小匿名指标。
|
||||
- 增加可复现 screenshot/accessibility 测试环境。
|
||||
- 准备隐私政策、内容来源、免责声明和应用商店素材。
|
||||
|
||||
@@ -71,7 +71,7 @@ Brainwave 帮助用户把一件正在纠结的事放慢来看。应用保留三
|
||||
- **FR-Q-001** 应用允许用户输入当前纠结的问题,建议长度 1–200 个 Unicode 字符。
|
||||
- **FR-Q-002** 问题字段必须有可见标签和隐私说明,不能仅使用占位文字。
|
||||
- **FR-Q-003** MVP 不要求账号、手机号或身份信息。
|
||||
- **FR-Q-004** 问题在本地保存与否必须由用户可见的设置或保存动作决定,不得默认上传。
|
||||
- **FR-Q-004** 问题是否在本地长期保存必须由首页说明、可见设置和当次操作共同控制;任何本地保存状态都不得被解释为上传授权。
|
||||
|
||||
### 5.2 投币与成卦
|
||||
|
||||
@@ -103,9 +103,13 @@ Brainwave 帮助用户把一件正在纠结的事放慢来看。应用保留三
|
||||
### 5.5 历史记录
|
||||
|
||||
- **FR-H-001** 历史记录属于 MVP 后半段能力;核心起卦不依赖它。
|
||||
- **FR-H-002** 保存时必须允许用户选择是否保留问题原文。
|
||||
- **FR-H-002** 设置提供“自动保存完整记录”总开关,以及问题原文、解读全文和行动记录三个内容开关;四项默认开启,总开关关闭时内容开关同步停用。
|
||||
- **FR-H-003** 删除记录必须可撤销或经过确认。
|
||||
- **FR-H-004** 未获得单独授权时,历史记录只保存在应用私有存储中。
|
||||
- **FR-H-004** 历史记录保存在应用私有存储中,并默认排除 Android Auto Backup、设备迁移数据提取和云备份;未来导出、备份或同步必须单独设计并获得明确选择。
|
||||
- **FR-H-005** 第六爻确认并锁定结果后,若自动保存开启,应用必须立即创建历史会话;未完成草稿不得进入长期历史。自动保存关闭时,结果页仍提供“保存本次”。
|
||||
- **FR-H-006** 自动保存的完整记录包含可复核起卦快照,并按内容开关保存问题原文、当次解读全文和行动记录;设置默认值均为开启。
|
||||
- **FR-H-007** 首页必须持续说明“起卦与历史默认保存在本机,不主动上传。只有你选择 AI 解读时,本次所需内容才会发送。”并提供设置入口;结果页必须显示当次保存状态,并允许“本次不保存/删除本次记录”。
|
||||
- **FR-H-008** 当次稍后生成的本地或 AI 解读必须按设置关联到同一会话;重新解读不得静默覆盖既有解读,必须新增版本或由用户明确确认替换。自动保存不能触发或授权 AI 请求。
|
||||
|
||||
## 6. 非功能需求
|
||||
|
||||
@@ -113,6 +117,7 @@ Brainwave 帮助用户把一件正在纠结的事放慢来看。应用保留三
|
||||
- **NFR-DET-001** 相同的六次投币输入和同一数据版本必须产生完全相同的本卦、动爻和之卦。
|
||||
- **NFR-SEC-001** 开发者 AI 密钥不得进入 APK、源码仓库或客户端日志。
|
||||
- **NFR-PRI-001** 问题文本、解释全文和模型请求不得写入分析埋点或崩溃日志。
|
||||
- **NFR-PRI-002** Room 历史数据库及其辅助文件不得进入系统自动备份、设备迁移或任何未单独授权的云端副本。
|
||||
- **NFR-A11Y-001** 正文对比度至少 4.5:1;交互目标至少 48×48dp;支持系统字体缩放和读屏。
|
||||
- **NFR-PERF-001** 本地起卦为纯内存同步计算,用户确认后应即时完成,不显示伪加载动画。
|
||||
- **NFR-RES-001** 旋转、切后台和可恢复的进程重建后保留当前流程状态。
|
||||
@@ -139,5 +144,6 @@ MVP 可以交付的最低条件:
|
||||
5. 用户能清楚分辨“经典/本地内容”“AI 解释”和“建议行动”。
|
||||
6. 无障碍检查覆盖投币控件、卦象、动爻和长文阅读。
|
||||
7. 64 卦及爻辞内容通过完整性、来源和授权门禁。
|
||||
8. 默认设置下完成起卦会生成一条仅在本机的完整记录;用户可以全局关闭、当次退出、单条删除或清空全部,且这些操作不会触发网络请求。
|
||||
|
||||
需求与验证方式的对应关系见[质量门禁](quality-gates.md#4-需求追踪矩阵)。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 移动端交互原型
|
||||
|
||||
> 状态:高保真候选 v0.1,待用户视觉与流程确认
|
||||
> 状态:高保真候选 v0.3;核心 v0.1 已确认,首页、问卦簿与默认本机保存流程已实现并待视觉复核
|
||||
> 基准视口:360 × 792 CSS px(对应当前目标手机的紧凑竖屏体验)
|
||||
> 实现位置:[`prototype/`](../prototype/)
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
原型已经覆盖:
|
||||
|
||||
- 欢迎与方法说明;
|
||||
- 首次欢迎与回访首页两种状态;
|
||||
- 首页唯一主操作、“最近一次”和问卦簿次级入口;
|
||||
- 开放式问题输入,以及明确的“不写具体内容”入口;
|
||||
- 三枚硬币逐枚录入、字/背计值、六轮 bottom-up 进度和撤销上一爻;
|
||||
- 本地确定性计算本卦、之卦和全部动爻;
|
||||
@@ -22,13 +24,17 @@
|
||||
- 经典原文、本地白话和动爻的来源标签;
|
||||
- “解”的本地/AI 两种分支;
|
||||
- AI 发送范围说明、逐次同意、加载、成功、失败和本地降级;
|
||||
- 首页本机保存/AI 发送边界说明与保存设置入口;
|
||||
- 默认开启的完整记录自动保存、总开关与三个内容开关;
|
||||
- 结果保存状态、当次不保存、自动保存关闭后的“保存本次”和解读关联反馈;
|
||||
- 问卦簿列表、空状态、记录详情、单条删除和清空全部确认;
|
||||
- 减少动态效果、键盘焦点、语义标签和最小触控目标。
|
||||
|
||||
本轮不包含:
|
||||
|
||||
- Android/Compose 生产代码;
|
||||
- 深色主题、平板和横屏定稿;
|
||||
- 历史记录与设置页面;
|
||||
- Room 真实持久化、历史迁移和系统 backup/data-extraction 配置;
|
||||
- 真实 AI 请求、账户、服务端或持久化;
|
||||
- 完整 64 卦授权内容包;
|
||||
- 正式名称、图标、字体授权和商店视觉。
|
||||
@@ -72,6 +78,16 @@ node prototype\capture.mjs
|
||||
| AI 解读 | `?view=explanation` | 来源标签、非裁决语言、行动收束 |
|
||||
| 本地解读 | `?view=local` | 离线分支与来源区分 |
|
||||
| AI 失败 | `?view=error` | 结果保留、重试和本地降级 |
|
||||
| 回访首页 | `?view=home` | 单一主操作、最近一次、问卦簿入口 |
|
||||
| 回访首页空态 | `?view=home-empty` | 无记录时不显示空功能宫格 |
|
||||
| 问卦簿 | `?view=history` | 倒序列表、问题/解释保存状态 |
|
||||
| 问卦簿空态 | `?view=history-empty` | 空态说明与开始入口 |
|
||||
| 历史详情 | `?view=history-detail` | 起卦快照、版本和已保存内容 |
|
||||
| 保存设置默认态 | `?view=settings` | 四个开关默认开启、本机与备份边界 |
|
||||
| 保存设置关闭态 | `?view=settings-off` | 总开关关闭、内容开关保留值并停用 |
|
||||
| 本次未保存结果 | `?view=result-not-saved` | 未保存原因和“保存本次”恢复路径 |
|
||||
| 删除确认 | `?view=delete` | 级联删除范围和再次确认 |
|
||||
| 清空全部确认 | `?view=clear-all` | 全量删除范围、设置保持不变 |
|
||||
|
||||
这些参数只设置初始预览状态,不属于 Android 正式版路由。
|
||||
|
||||
@@ -114,6 +130,19 @@ node prototype\capture.mjs
|
||||
|
||||
失败状态不得隐藏或重算卦象,必须同时提供本地解释和重试入口。
|
||||
|
||||
### 5.4 首页与历史
|
||||
|
||||
- 首次欢迎页继续保持单一“开始”操作;有历史的回访首页使用更紧凑的开场,并在主操作之后展示最近一次。
|
||||
- 首页不显示空的未来功能卡片。扩展区只渲染已上线入口;至少出现 3 个同级长期功能后才重新评估底部导航。
|
||||
- 首页显示:“起卦与历史默认保存在本机,不主动上传。只有你选择 AI 解读时,本次所需内容才会发送。”并提供“管理保存设置”。
|
||||
- 第六爻锁定后默认自动创建本机会话;结果页显示保存状态,并支持“本次不保存/删除本次记录”。总开关关闭时显示“本次未保存”和“保存本次”。
|
||||
- 设置页提供默认开启的总开关、问题原文、解读全文和行动记录开关;总开关关闭时其余控制同步停用。清空全部必须二次确认。
|
||||
- 本地或 AI 解读按设置加入同一会话;自动保存与 AI 同意完全独立,保存过程不得产生网络调用。
|
||||
- 问卦簿中的演示记录只存在内存。刷新会恢复预览夹具,不能把原型行为误称为 Room 已实现。
|
||||
- 删除必须经过确认;正式版级联删除会话、解释和行动记录。
|
||||
|
||||
这些状态在原型中使用内存模拟,刷新后恢复预览夹具;它们定义 Compose 的交互契约,但不代表 Room、DataStore 或备份排除已经实现。
|
||||
|
||||
## 6. 视觉系统
|
||||
|
||||
视觉遵循 [ADR-006](decisions.md#adr-006东方文化采用内容优先的纸墨朱砂设计):
|
||||
@@ -144,6 +173,18 @@ node prototype\capture.mjs
|
||||
6. `06-ai-consent.png`
|
||||
7. `07-ai-explanation.png`
|
||||
8. `08-ai-error.png`
|
||||
9. `09-home-returning.png`
|
||||
10. `10-home-empty.png`
|
||||
11. `11-history-list.png`
|
||||
12. `12-history-empty.png`
|
||||
13. `13-history-detail.png`
|
||||
14. `14-settings-default.png`
|
||||
15. `15-delete-confirm.png`
|
||||
16. `16-settings-off.png`
|
||||
17. `17-result-not-saved.png`
|
||||
18. `18-clear-all-confirm.png`
|
||||
|
||||
目录中的 `14-save-record.png` 与 `16-save-with-explanation.png` 是 v0.2 手动保存流程的历史评审证据,当前截图脚本不再生成,不能用于 v0.3 验收。
|
||||
|
||||
截图是 360 CSS px 宽度的评审证据,不是应用商店素材。
|
||||
|
||||
@@ -151,13 +192,15 @@ node prototype\capture.mjs
|
||||
|
||||
进入 Android P0/P3 前,需要用户明确反馈或确认:
|
||||
|
||||
- [ ] “纸、墨、朱砂、留白”的整体气质是否合适;
|
||||
- [ ] 工作名只作占位,不把“一问”直接视为正式名称;
|
||||
- [ ] 三枚硬币用圆形切换控件录入是否容易理解;
|
||||
- [ ] 结果页先卦象、后原文/白话、再点击“解”的层级是否合适;
|
||||
- [ ] 本地解读与 AI 解读的选择、同意和来源标签是否足够清楚;
|
||||
- [ ] “可以试的一小步”的非预测、非裁决语气是否符合定位;
|
||||
- [ ] 是否需要在下一轮原型加入历史记录、深色主题或更大字号状态。
|
||||
- [x] “纸、墨、朱砂、留白”的整体气质;
|
||||
- [x] 工作名只作占位,不把“一问”直接视为正式名称;
|
||||
- [x] 三枚硬币圆形切换控件与结果阅读层级;
|
||||
- [x] 本地/AI 选择、同意门和非裁决式行动语气;
|
||||
- [x] 产品已确认历史默认本机自动保存、可全局关闭和当次退出(ADR-012);
|
||||
- [ ] 回访首页的“最近一次 / 问卦簿”层级是否保持了主流程焦点;
|
||||
- [ ] v0.3 首页告知、默认开启设置、结果保存状态、当次退出和清空全部是否足够清楚;
|
||||
- [ ] 问卦簿列表、空态、详情和删除确认是否适合进入 Compose 实现;
|
||||
- [ ] 是否在下一轮原型加入深色主题或更大字号状态。
|
||||
|
||||
未确认项应继续停留在原型层修改;不应提前固化为 Compose 生产页面。
|
||||
|
||||
@@ -167,11 +210,13 @@ node prototype\capture.mjs
|
||||
|---|---|
|
||||
| CSS 颜色/间距/圆角令牌 | `MaterialTheme` 扩展与 design token |
|
||||
| `welcome/question/casting/result` | Navigation destination + 状态驱动页面 |
|
||||
| `home/history/history-detail` | 顶层首页 + Room `Flow` 驱动的列表/详情 |
|
||||
| 三枚硬币控件 | 具备明确 semantics 的 48dp+ Compose 控件 |
|
||||
| 六爻图 | Canvas 绘制 + 完整读屏描述 |
|
||||
| 底部选择/同意层 | Material 3 modal bottom sheet/dialog |
|
||||
| 本地/AI/错误来源标签 | sealed UI state,不以颜色作为唯一提示 |
|
||||
| `prefers-reduced-motion` | 系统动画缩放/无障碍偏好适配 |
|
||||
| 保存状态/设置/删除确认 | `HistorySavePolicy` 状态驱动 UI + 事务保存/级联删除用例 |
|
||||
|
||||
Compose 实现必须从 `CastResult` 渲染,不能在 UI 层复制 King Wen 映射或根据文本猜测卦象。
|
||||
|
||||
@@ -179,12 +224,20 @@ Compose 实现必须从 `CastResult` 渲染,不能在 UI 层复制 King Wen
|
||||
|
||||
- `node --check prototype/app.js`:通过。
|
||||
- `node --check prototype/capture.mjs`:通过。
|
||||
- `node prototype/capture.mjs`:通过;八个状态完成 360px 截图与浏览器审计。
|
||||
- `node prototype/capture.mjs`:通过;十八个状态完成 360px 截图与浏览器审计。
|
||||
- `git diff --check`:通过。
|
||||
- Chromium 360 × 792:欢迎、起念、录入、有/无动爻结果、AI 同意、成功与失败状态已渲染。
|
||||
- Chromium 360 × 792:核心流程、首页、历史、保存设置、自动保存/未保存状态、删除与清空确认均已渲染。
|
||||
- 已知夹具 `9,8,8,8,8,8`:显示复 24 → 坤 2,初爻动。
|
||||
- 静态夹具 `7,7,7,7,7,7`:显示乾 1,无动爻且无之卦。
|
||||
- 浏览器控制台错误:0;运行时异常:0;外部请求:0;模型调用:0。
|
||||
- 八个状态的未命名按钮、低于 48px 的按钮触控目标、过小复选框标签:均为 0。
|
||||
- 首页只有 1 个主流程开始按钮且没有底部导航;问卦簿演示列表为 3 条,删除确认后为 2 条。
|
||||
- 四个保存开关默认开启;关闭总开关后三个内容开关保留值并停用。
|
||||
- 结果默认显示自动保存状态;“本次不保存”删除当次记录后可重新“保存本次”,且不会改变全局设置。
|
||||
- AI 解读生成后显示“解读已加入本次记录”;保存、设置、历史与删除流程产生的外部请求均为 0。
|
||||
- 清空全部会删除三条演示记录、保留自动保存设置,并返回可操作的设置页。
|
||||
- 375×812、430×932 与 792×360:无水平溢出,主内容可达;回访首页前进/返回恢复正确。
|
||||
- `prefers-reduced-motion: reduce`:页面动效压缩至近零时长。
|
||||
- 新增成功、危险和次级文字组合的对比度均不低于 4.97:1。
|
||||
- 十八个状态的未命名按钮、低于 48px 的按钮触控目标、过小开关/复选框标签:均为 0。
|
||||
|
||||
原型验证不能替代 P1 的 4,096 组合领域测试,也不能替代 Android 真机、TalkBack 与最大字体测试。
|
||||
原型验证不能替代 P1 的 4,096 组合领域测试,也不能替代 Android 真机、TalkBack 与最大字体测试。v0.3 已满足 ADR-012 的原型门禁,但仍需用户完成视觉复核后才能映射到 Compose。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 质量门禁与验证策略
|
||||
|
||||
> 状态:测试策略已定义;命令在 Android 骨架初始化后启用
|
||||
> 状态:JVM/harness 门禁已启用;Android lint、构建、UI 与设备门禁等待 Android application 壳
|
||||
> 原则:完成必须有可重复证据,不能以“代码看起来正确”代替验证
|
||||
|
||||
## 1. 反馈循环
|
||||
@@ -17,7 +17,18 @@
|
||||
|
||||
## 2. 预期本地命令
|
||||
|
||||
工程创建后,Windows 环境至少提供以下稳定入口:
|
||||
当前可重复的 Windows 快速门禁:
|
||||
|
||||
```powershell
|
||||
.\gradlew.bat spotlessCheck --offline
|
||||
.\gradlew.bat :app:test --offline
|
||||
.\gradlew.bat verifyContentContract --offline
|
||||
.\gradlew.bat verifyLocal --offline
|
||||
```
|
||||
|
||||
`verifyLocal` 当前聚合无依赖格式检查、10 个领域测试、3 个内容 repository 测试、已解析 JVM 依赖许可证、domain 依赖边界、内容契约与负向夹具、文档链接、高置信 secret scan 和原型 JavaScript 语法检查。CI 执行同一个聚合任务。
|
||||
|
||||
Android application 插件配置后,Windows 环境还必须提供:
|
||||
|
||||
```powershell
|
||||
.\gradlew.bat spotlessCheck
|
||||
@@ -29,29 +40,29 @@
|
||||
|
||||
若采用不同格式化插件,命令可以调整,但必须在本文件和 CI 同步更新。`connectedDebugAndroidTest` 需要模拟器或设备,应与纯 JVM 快速门禁分开。
|
||||
|
||||
建议再提供聚合任务:
|
||||
|
||||
```powershell
|
||||
.\gradlew.bat verifyLocal
|
||||
```
|
||||
|
||||
它至少依赖格式、lint、JVM 单元测试和 debug 构建,使代理不必猜测正确验证组合。
|
||||
|
||||
当前仓库没有 Gradle Wrapper,所以上述命令尚未运行,也不能报告为通过。
|
||||
到那时必须把 Android lint 和 debug build 加入现有 `verifyLocal`,使代理仍不必猜测验证组合。在完成这一步之前,`verifyLocal` 通过只证明 JVM 与仓库门禁,不代表 APK 已构建或真机测试已通过。
|
||||
|
||||
## 3. 测试层次
|
||||
|
||||
### 3.0 原型验证(P-1 阻塞视觉确认)
|
||||
|
||||
当前 v0.3 原型已经同步 ADR-012;以下门禁既覆盖核心起卦流程,也覆盖默认本机自动保存策略。
|
||||
|
||||
- `node --check prototype/app.js` 无语法错误。
|
||||
- `node --check prototype/capture.mjs` 无语法错误。
|
||||
- `git diff --check` 无空白错误。
|
||||
- 本地服务运行时,`node prototype\capture.mjs` 退出码为 0。
|
||||
- Chromium 360 × 792 走通问题输入、六次录入、结果和解释选择。
|
||||
- Chromium 360 × 792 走通首页、问题输入、六次录入、结果、解释选择和问卦簿。
|
||||
- `9,8,8,8,8,8` 显示复 24 → 坤 2、初爻动;全 7 显示乾 1、无动爻且无之卦。
|
||||
- 欢迎、起念、录入、有/无动爻结果、AI 同意、解释和失败状态均有可复现截图。
|
||||
- 十八个状态均有可复现截图,包括回访首页/空态、历史列表/空态/详情、保存设置开/关、自动保存/本次未保存、删除和清空确认。
|
||||
- 回访首页只有一个开始主操作,不出现底部导航或未上线功能占位。
|
||||
- 首页持续显示本机保存/AI 发送边界;四个保存设置默认开启,总开关关闭后内容开关保留值并停用。
|
||||
- 完成起卦显示自动保存状态;当次不保存、重新保存、解释关联和清空全部均可复现,删除前显示范围并再次确认。
|
||||
- 375×812、430×932 和 792×360 无水平溢出;减少动态效果媒体偏好生效。
|
||||
- 浏览器控制台无应用错误;页面不发起外部请求;AI 状态明确标为本地模拟。
|
||||
|
||||
自动化审计必须同时断言:以上保存、设置、历史和删除动作产生的外部请求为 0,且 AI 同意门仍须用户单独勾选。
|
||||
|
||||
原型通过只表示流程可供评审,不替代纯 Kotlin 领域测试、Compose UI 测试或真机无障碍检查。
|
||||
|
||||
### 3.1 纯 JVM 领域测试(最快,阻塞合并)
|
||||
@@ -72,7 +83,14 @@
|
||||
- 只在用户点击且同意后调用 AI。
|
||||
- 并发点击、取消和超时不重复提交。
|
||||
- Room 保存/读取一致性。
|
||||
- 删除与撤销/确认行为。
|
||||
- `autoSaveHistory`、问题、解读和行动四个设置的默认值均为 `true`,且总开关关闭时不自动写入。
|
||||
- 第六爻锁定只创建一个 session,未完成草稿创建零条;总开关关闭时“保存本次”仍能创建一条。
|
||||
- 问题、解读和行动内容开关的组合写入准确;当次不保存会删除已自动创建的 session,且不改变全局设置。
|
||||
- 随后产生的本地/AI 解读关联到同一 session;重新解读不会静默覆盖旧版本。
|
||||
- 自动保存开启、读取历史或切换保存设置时,AI gateway 收到零次调用;AI 同意状态不被保存设置改变。
|
||||
- “最近一次”查询随插入/删除更新,且不复制敏感正文。
|
||||
- 单条删除、清空全部与 session → explanation/action 的级联行为。
|
||||
- backup/data-extraction 配置测试确认 Room 数据库及 `-wal`、`-shm` 辅助文件不在系统备份或设备迁移范围。
|
||||
|
||||
测试优先使用接口的 fake 实现,不依赖真实网络和实时模型。
|
||||
|
||||
@@ -82,6 +100,10 @@
|
||||
- 六轮录入的进度、按钮启用状态和错误恢复。
|
||||
- 结果页本卦/之卦/动爻语义。
|
||||
- AI 同意、加载、失败、本地降级。
|
||||
- 回访首页、最近一次、问卦簿列表/空态/详情和删除确认。
|
||||
- 首页本机保存/AI 发送说明和设置入口始终可达。
|
||||
- 保存设置默认开启、总开关联动停用、结果页自动保存状态、当次不保存/保存本次和解释关联反馈。
|
||||
- 清空全部的危险操作样式、范围说明和二次确认。
|
||||
- 最大字体下关键操作可到达。
|
||||
- TalkBack 语义、焦点顺序和触控目标。
|
||||
- 旋转/横屏/大屏布局。
|
||||
@@ -93,6 +115,7 @@
|
||||
- 使用已知六爻夹具人工核对卦象绘制和文本。
|
||||
- 后端 staging 环境验证同意、超时、限流、非法响应和安全分支。
|
||||
- 验证安装包不包含生产密钥和未授权内容。
|
||||
- 从系统备份/设备迁移恢复后验证问卦簿为空,且设置页未暗示历史已经云端备份。
|
||||
|
||||
## 4. 需求追踪矩阵
|
||||
|
||||
@@ -107,7 +130,7 @@
|
||||
| FR-R-005 | 内容 schema/授权清单检查 | 内容负责人签核 |
|
||||
| FR-E-001~003 | 网络调用次数与同意状态测试 | 首次同意流程 |
|
||||
| FR-E-004~006 | 输出 schema + 安全用例集 | 安全/产品审核 |
|
||||
| FR-H-001~004 | Room、删除、权限边界测试 | 隐私设置与删除体验 |
|
||||
| FR-H-001~008 | 保存策略、Room 事务/版本、删除、备份排除、零网络调用测试 | 首页告知、设置、当次退出与删除体验 |
|
||||
| NFR-OFF-001 | fake/offline 集成测试 | 飞行模式真机 |
|
||||
| NFR-DET-001 | 4,096 组合属性测试 | 无 |
|
||||
| NFR-SEC/PRI | secret scan、日志测试 | APK/代理抓包复核 |
|
||||
@@ -122,11 +145,14 @@
|
||||
- `data.ai` 不得依赖或调用 `CastEngine`。
|
||||
- feature UI 不得引用 DAO 或网络 DTO。
|
||||
- 生产源码不得出现 API key 形态、真实用户问题测试样例或 HTTP body logger。
|
||||
- backup/data-extraction 配置不得包含历史 Room 数据库或其辅助文件。
|
||||
- 内容校验任务验证 64 卦、384 条爻辞、唯一模式、来源和许可证字段。
|
||||
- 文档链接和需求编号无悬空引用。
|
||||
|
||||
可以使用现有静态工具、架构测试库或小型自定义 Gradle 任务;具体选型写入[决策记录](decisions.md)。规则的错误消息应告诉代理如何修复,而不只报告失败。
|
||||
|
||||
当前由无外部依赖的 Node 脚本与 Gradle 任务执行上述已落地规则,见 ADR-013。引入 Android 源码后应扩展同一入口,不应建立一套互不相干的新命令。
|
||||
|
||||
## 6. 发布门禁
|
||||
|
||||
发布候选必须满足:
|
||||
|
||||
@@ -21,11 +21,15 @@
|
||||
|
||||
## 2. 信息架构
|
||||
|
||||
MVP 顶层不需要底部导航。主流程是线性的,使用单 Activity 和清晰的返回行为:
|
||||
MVP 顶层不需要底部导航。首次欢迎与回访首页是不同状态;核心流程仍保持线性,并使用单 Activity 和清晰的返回行为:
|
||||
|
||||
```text
|
||||
欢迎/方法说明
|
||||
└── 起念
|
||||
首次欢迎/方法说明
|
||||
└── 回访首页
|
||||
├── 开始一问 → 起念
|
||||
└── 最近一次 / 问卦簿
|
||||
|
||||
起念
|
||||
└── 投币(1/6…6/6)
|
||||
└── 结果
|
||||
├── 本地解释
|
||||
@@ -54,7 +58,26 @@ MVP 顶层不需要底部导航。主流程是线性的,使用单 Activity 和
|
||||
|
||||
主操作只有一个:“开始”。“查看方法”是次级文本操作,不制造必须完成的教程轮播。
|
||||
|
||||
### 3.2 起念
|
||||
### 3.2 回访首页与功能扩展
|
||||
|
||||
目的:让回访用户快速开始新的一问,同时能回看默认保存在本机、由自己控制的记录。
|
||||
|
||||
首页层级固定为:
|
||||
|
||||
1. 唯一主操作“开始一问”;
|
||||
2. “你的观照”次级区域;
|
||||
3. 存在记录时显示最近一次及“查看问卦簿”;无记录时显示简短空态和隐私说明。
|
||||
|
||||
扩展规则:
|
||||
|
||||
- 不展示“敬请期待”、禁用卡片或空宫格;只有已上线且用户可进入的功能才出现。
|
||||
- MVP 只显示问卦簿,不为了预留而引入底部导航。
|
||||
- 当出现至少 3 个长期、同级、需要频繁切换的顶层功能时,才评估带文字标签的底部导航;最多 5 项。
|
||||
- “最近一次”由历史查询派生,不复制问题或解释正文;记录删除后首页同步显示下一条或空态。
|
||||
- 最近卡片必须同时显示日期、卦名、动爻概况和解释来源;问题因设置或当次选择未保存时明确写“未保存问题原文”。
|
||||
- 首页在主操作附近持续显示:“起卦与历史默认保存在本机,不主动上传。只有你选择 AI 解读时,本次所需内容才会发送。”末尾提供可点击的“管理保存设置”,但不能把设置做成开始流程的前置门。
|
||||
|
||||
### 3.3 起念
|
||||
|
||||
页面标题可使用“此刻,你在为何事迟疑?”。问题输入框有固定标签“正在纠结的事”,辅文提示尽量描述事实、选择和顾虑,不要求生日、性别等无关信息。
|
||||
|
||||
@@ -65,7 +88,7 @@ MVP 顶层不需要底部导航。主流程是线性的,使用单 Activity 和
|
||||
- 默认不把问题发送到网络;
|
||||
- 用户可选择“不写具体内容,直接开始”,最终是否允许空值见[决策记录](decisions.md#未决问题)。
|
||||
|
||||
### 3.3 投币
|
||||
### 3.4 投币
|
||||
|
||||
页面始终显示:
|
||||
|
||||
@@ -79,7 +102,7 @@ MVP 顶层不需要底部导航。主流程是线性的,使用单 Activity 和
|
||||
|
||||
每次确认可以有一次轻触觉反馈和 150–250ms 的爻线出现动画。不得使用持续摇晃、金币飞散、音效倒计时或强制等待。开启“减少动态效果”时直接更新状态。
|
||||
|
||||
### 3.4 成卦与观象
|
||||
### 3.5 成卦与观象
|
||||
|
||||
信息优先级:
|
||||
|
||||
@@ -93,7 +116,7 @@ MVP 顶层不需要底部导航。主流程是线性的,使用单 Activity 和
|
||||
|
||||
原文、本地白话和 AI 解释必须有明确标签,不使用视觉相似但来源不明的段落混排。
|
||||
|
||||
### 3.5 解读与落事
|
||||
### 3.6 解读与落事
|
||||
|
||||
「解」是结果页唯一主操作。点击后先选择或显示当前方式:
|
||||
|
||||
@@ -104,6 +127,39 @@ AI 加载超过 300ms 时显示内联进度;按钮在请求期间禁用,避
|
||||
|
||||
结尾固定使用“可以试的一小步”区域,包含时间范围和撤销方式;它是建议,不是判词。
|
||||
|
||||
### 3.7 保存与问卦簿
|
||||
|
||||
第六爻确认并生成结果后,默认在后台立即创建本机会话。结果页在不打断阅读的位置显示“已保存到本机问卦簿”,并提供次级操作“本次不保存/删除本次记录”;它们不能抢占结果页唯一主操作「解」。自动保存关闭时,同一位置显示“本次未保存”和“保存本次”。
|
||||
|
||||
设置页必须提供以下控制:
|
||||
|
||||
- “自动保存完整记录”,默认开启;
|
||||
- “保存问题原文”,默认开启;
|
||||
- “保存解读全文”,默认开启;
|
||||
- “保存行动记录”,默认开启;
|
||||
- “清空全部问卦记录”,使用危险操作样式并再次确认。
|
||||
|
||||
关闭总开关后,三个内容开关保持其值但在界面中同步停用,便于再次开启时恢复用户偏好。总开关不影响未完成草稿的进程恢复,也不能改变 AI 同意状态。
|
||||
|
||||
保存边界必须分层说明:
|
||||
|
||||
- 固定快照包含 18 枚币面、六爻结果、动爻、方法版本和内容版本;
|
||||
- 问题原文、当次解读和行动记录由各自开关控制;
|
||||
- 未完成问题与投币只用于当前流程恢复,不进入问卦簿;
|
||||
- 存储范围是应用私有本机存储,默认不进入系统备份、设备迁移或云同步;
|
||||
- 本地保存与 AI 发送是两个独立决定,自动保存绝不触发网络请求。
|
||||
|
||||
本地或 AI 解读在生成后应自动关联到当次会话,并给出轻量的“已加入本次记录”反馈。重新解读默认新增版本;若产品只保留一个版本,覆盖前必须让用户明确确认。
|
||||
|
||||
问卦簿要求:
|
||||
|
||||
- 列表按保存时间倒序,展示卦名、动爻概况、问题保存状态和解释来源;
|
||||
- 空状态说明“完成起卦后会默认保存在本机;你可以在设置中关闭”,并提供“开始第一次记录”和“管理保存设置”;
|
||||
- 详情展示保存时的卦象快照、问题状态、解释来源、方法/内容版本和可选行动;
|
||||
- 删除使用明确确认对话框,并级联删除该会话关联的解释和行动记录;
|
||||
- “清空全部”必须在设置或问卦簿菜单中二次确认,并清楚说明不可恢复范围;
|
||||
- 返回列表后保留原滚动位置;从问卦簿开始新流程时,返回行为不得跳到首次欢迎页。
|
||||
|
||||
## 4. 视觉系统
|
||||
|
||||
### 4.1 风格关键词
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
[versions]
|
||||
kotlin = "1.9.20"
|
||||
junit = "4.13.2"
|
||||
|
||||
[libraries]
|
||||
junit = { module = "junit:junit", version.ref = "junit" }
|
||||
|
||||
[plugins]
|
||||
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||
@@ -0,0 +1,8 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
|
||||
distributionSha256Sum=38f66cd6eef217b4c35855bb11ea4e9fbc53594ccccb5fb82dfd317ef8c2c5a3
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command;
|
||||
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||
# shell script including quotes and variable substitutions, so put them in
|
||||
# double quotes to make sure that they get re-expanded; and
|
||||
# * put everything else in single quotes, so that it's not re-expanded.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -0,0 +1,92 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -10,17 +10,20 @@ python -m http.server 4173 --bind 127.0.0.1
|
||||
|
||||
打开 `http://127.0.0.1:4173/prototype/`。完整状态入口、领域边界、评审清单和 Android 映射见 [`docs/prototype.md`](../docs/prototype.md)。
|
||||
|
||||
服务保持运行时,可用当前系统的 Chrome/Edge 重新生成八张截图并执行浏览器审计:
|
||||
服务保持运行时,可用当前系统的 Chrome/Edge 重新生成 18 张截图并执行浏览器审计:
|
||||
|
||||
```powershell
|
||||
node prototype\capture.mjs
|
||||
```
|
||||
|
||||
脚本使用 Node 22 自带的 WebSocket 直接连接 Chrome DevTools,不安装 npm 包;它会核对两个已知卦象、AI 同意门、控制台/外部请求、按钮名称和 48px 最小触控目标。任一门禁失败时退出码为非零。
|
||||
脚本使用 Node 22 自带的 WebSocket 直接连接 Chrome DevTools,不安装 npm 包;它会生成 18 个评审状态,并核对两个已知卦象、AI 同意门、首页层级、历史列表/空态、保存设置、自动保存/当次退出、解读关联、删除/清空流程、控制台/外部请求、按钮名称和 48px 最小触控目标。任一门禁失败时退出码为非零。
|
||||
|
||||
当前代码是 v0.3 高保真候选,已经同步 ADR-012 的默认本机自动保存策略;所有记录和设置仍只存在于浏览器内存,用于评审而非真实持久化。
|
||||
|
||||
约束:
|
||||
|
||||
- 不发起外部请求,不调用 AI;
|
||||
- 不生成随机卦,用户逐枚录入硬币;
|
||||
- 查询参数只用于稳定预览和截图;
|
||||
- 问卦簿使用内存演示数据,刷新会恢复夹具,不代表 Room 已实现;
|
||||
- 工作名“一问”与当前文字内容均为评审候选,不代表正式命名或内容授权完成。
|
||||
|
||||
@@ -3,6 +3,32 @@
|
||||
|
||||
const SAMPLE_QUESTION = "我是否要接受这次工作调整?怎样做能更稳妥?";
|
||||
const SAMPLE_LINES = [9, 8, 8, 8, 8, 8];
|
||||
const SAMPLE_HISTORY = [
|
||||
{
|
||||
id: "sample-1",
|
||||
date: "今天 · 21:10",
|
||||
lines: [9, 8, 8, 8, 8, 8],
|
||||
question: SAMPLE_QUESTION,
|
||||
explanationSource: "AI 生成",
|
||||
action: "写下三条已知事实,再约一次不超过 20 分钟的信息沟通。"
|
||||
},
|
||||
{
|
||||
id: "sample-2",
|
||||
date: "昨天 · 07:40",
|
||||
lines: [8, 8, 7, 8, 8, 8],
|
||||
question: null,
|
||||
explanationSource: "本地解读",
|
||||
action: null
|
||||
},
|
||||
{
|
||||
id: "sample-3",
|
||||
date: "7 月 30 日 · 22:15",
|
||||
lines: [7, 7, 8, 8, 7, 6],
|
||||
question: "这段合作应该怎样调整边界?",
|
||||
explanationSource: null,
|
||||
action: "先把需要确认的职责写成一页清单。"
|
||||
}
|
||||
];
|
||||
|
||||
const HEXAGRAM_NAMES = [
|
||||
"", "乾", "坤", "屯", "蒙", "需", "讼", "师", "比", "小畜", "履", "泰", "否",
|
||||
@@ -29,7 +55,8 @@
|
||||
lock: '<svg aria-hidden="true" viewBox="0 0 24 24"><rect x="5" y="10" width="14" height="10" rx="2"/><path d="M8.5 10V7.5a3.5 3.5 0 017 0V10"/></svg>',
|
||||
arrow: '<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 12h14M14 7l5 5-5 5"/></svg>',
|
||||
chevron: '<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M9 6l6 6-6 6"/></svg>',
|
||||
shield: '<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3l7 3v5c0 4.7-2.8 8-7 10-4.2-2-7-5.3-7-10V6l7-3z"/><path d="M9.2 12l1.8 1.8 3.9-4"/></svg>'
|
||||
shield: '<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3l7 3v5c0 4.7-2.8 8-7 10-4.2-2-7-5.3-7-10V6l7-3z"/><path d="M9.2 12l1.8 1.8 3.9-4"/></svg>',
|
||||
book: '<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 5.5A2.5 2.5 0 016.5 3H11v16H6.5A2.5 2.5 0 004 21.5v-16zM20 5.5A2.5 2.5 0 0017.5 3H13v16h4.5a2.5 2.5 0 012.5 2.5v-16z"/></svg>'
|
||||
};
|
||||
|
||||
const screen = document.querySelector("#screen");
|
||||
@@ -45,9 +72,27 @@
|
||||
modal: null,
|
||||
consent: false,
|
||||
explanationSource: "ai",
|
||||
aiError: false
|
||||
aiError: false,
|
||||
historyRecords: [],
|
||||
selectedRecordId: null,
|
||||
autoSaveHistory: true,
|
||||
saveQuestion: true,
|
||||
saveExplanation: true,
|
||||
saveAction: true,
|
||||
saved: false,
|
||||
currentRecordId: "current-session",
|
||||
currentSessionOptedOut: false,
|
||||
currentSaveMode: null,
|
||||
explanationGenerated: false,
|
||||
questionOrigin: "welcome",
|
||||
settingsOrigin: "home",
|
||||
deleteReturnScreen: "history"
|
||||
};
|
||||
|
||||
function sampleHistory() {
|
||||
return SAMPLE_HISTORY.map((record) => ({ ...record, lines: [...record.lines] }));
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
@@ -103,6 +148,59 @@
|
||||
};
|
||||
}
|
||||
|
||||
function hasSavableQuestion() {
|
||||
return Boolean(state.question && !state.question.startsWith("未记录具体问题"));
|
||||
}
|
||||
|
||||
function generatedExplanationSource() {
|
||||
if (!state.explanationGenerated || !state.saveExplanation) return null;
|
||||
return state.explanationSource === "ai" ? "AI 生成" : "本地解读";
|
||||
}
|
||||
|
||||
function currentSessionRecord() {
|
||||
return state.historyRecords.find((record) => record.id === state.currentRecordId);
|
||||
}
|
||||
|
||||
function persistCurrentSession(mode = "manual") {
|
||||
if (state.lines.length !== 6) return;
|
||||
const data = resultData();
|
||||
const existing = currentSessionRecord();
|
||||
const record = {
|
||||
id: state.currentRecordId,
|
||||
date: "刚刚",
|
||||
lines: [...data.sourceLines],
|
||||
question: state.saveQuestion && hasSavableQuestion() ? state.question : null,
|
||||
explanationSource: generatedExplanationSource(),
|
||||
action: state.explanationGenerated && state.saveAction
|
||||
? "写下三条已知事实,再进行一次短沟通。"
|
||||
: null
|
||||
};
|
||||
|
||||
if (existing) Object.assign(existing, record);
|
||||
else state.historyRecords.unshift(record);
|
||||
state.saved = true;
|
||||
state.currentSessionOptedOut = false;
|
||||
state.currentSaveMode = mode;
|
||||
}
|
||||
|
||||
function attachExplanationToCurrentSession() {
|
||||
const record = currentSessionRecord();
|
||||
if (!record || !state.saved) return;
|
||||
if (state.saveExplanation) record.explanationSource = generatedExplanationSource();
|
||||
if (state.saveAction) {
|
||||
record.action = "写下三条已知事实,再进行一次短沟通。";
|
||||
}
|
||||
}
|
||||
|
||||
function prepareNewSession() {
|
||||
state.currentRecordId = `current-session-${Date.now()}-${state.historyRecords.length}`;
|
||||
state.saved = false;
|
||||
state.currentSessionOptedOut = false;
|
||||
state.currentSaveMode = null;
|
||||
state.explanationGenerated = false;
|
||||
state.aiError = false;
|
||||
}
|
||||
|
||||
function stepper(current, total) {
|
||||
const progress = Math.round((current / total) * 100);
|
||||
return `
|
||||
@@ -141,6 +239,259 @@
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function historyRecordData(record) {
|
||||
const changedLines = transformLines(record.lines);
|
||||
const movingIndexes = record.lines
|
||||
.map((value, index) => lineIsMoving(value) ? index : -1)
|
||||
.filter((index) => index >= 0);
|
||||
return {
|
||||
primary: lookupHexagram(record.lines),
|
||||
changed: lookupHexagram(changedLines),
|
||||
changedLines,
|
||||
movingIndexes
|
||||
};
|
||||
}
|
||||
|
||||
function recordRoute(record) {
|
||||
const data = historyRecordData(record);
|
||||
return data.movingIndexes.length
|
||||
? `${data.primary.name} <span>之</span> ${data.changed.name}`
|
||||
: data.primary.name;
|
||||
}
|
||||
|
||||
function recentRecordCard(record) {
|
||||
const data = historyRecordData(record);
|
||||
return `
|
||||
<button class="recent-card" type="button" data-action="open-history-detail" data-record-id="${record.id}">
|
||||
<span class="recent-card__top">
|
||||
<span>${record.date}</span>
|
||||
<span class="source-badge">最近一次</span>
|
||||
</span>
|
||||
<span class="recent-card__route">${recordRoute(record)}</span>
|
||||
<span class="recent-card__question">${escapeHtml(record.question ?? "未保存问题原文")}</span>
|
||||
<span class="recent-card__meta">
|
||||
<span>${data.movingIndexes.length ? `${data.movingIndexes.length} 个动爻` : "无动爻"}</span>
|
||||
<span>${record.explanationSource ?? "仅保存卦象"}</span>
|
||||
${icons.chevron}
|
||||
</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function homeView() {
|
||||
const latest = state.historyRecords[0];
|
||||
return `
|
||||
<section class="page page--home" aria-labelledby="home-title">
|
||||
<header class="home-intro">
|
||||
<p class="eyebrow">慢一点,看清当下</p>
|
||||
<h1 id="home-title">此刻,有什么想安静看清?</h1>
|
||||
<p class="lede">你投币,应用记录;结果用于整理想法,不替你预测或决定。</p>
|
||||
<button class="button button--primary home-primary" type="button" data-action="begin">
|
||||
开始一问 ${icons.arrow}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="home-shelf" aria-labelledby="reflection-shelf-title">
|
||||
<div class="section-heading home-shelf__heading">
|
||||
<h2 id="reflection-shelf-title">你的观照</h2>
|
||||
<button class="text-link" type="button" data-action="open-history">查看问卦簿</button>
|
||||
</div>
|
||||
${latest ? recentRecordCard(latest) : `
|
||||
<button class="home-empty-card" type="button" data-action="open-history">
|
||||
<span class="home-empty-card__icon">${icons.book}</span>
|
||||
<span><strong>问卦簿尚无记录</strong><small>${state.autoSaveHistory ? "完成起卦后会默认保存在本机" : "自动保存已关闭,可在结果页保存本次"}</small></span>
|
||||
${icons.chevron}
|
||||
</button>`}
|
||||
<aside class="local-data-card" data-local-data-notice>
|
||||
<span class="local-data-card__icon">${icons.lock}</span>
|
||||
<div>
|
||||
<strong>起卦与历史默认保存在本机,不主动上传。</strong>
|
||||
<p>只有你选择 AI 解读时,本次所需内容才会发送。</p>
|
||||
<button class="text-link" type="button" data-action="open-settings">管理保存设置</button>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function historyCard(record) {
|
||||
const data = historyRecordData(record);
|
||||
return `
|
||||
<li>
|
||||
<button class="history-card" type="button" data-action="open-history-detail" data-record-id="${record.id}">
|
||||
<span class="history-card__top">
|
||||
<span>${record.date}</span>
|
||||
<span>${data.movingIndexes.length ? `${data.movingIndexes.length} 个动爻` : "无动爻"}</span>
|
||||
</span>
|
||||
<span class="history-card__route">${recordRoute(record)}</span>
|
||||
<span class="history-card__question">${escapeHtml(record.question ?? "未保存问题原文")}</span>
|
||||
<span class="history-card__footer">
|
||||
<span>${record.explanationSource ?? "仅保存卦象"}</span>
|
||||
${icons.chevron}
|
||||
</span>
|
||||
</button>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function historyView() {
|
||||
const hasRecords = state.historyRecords.length > 0;
|
||||
return `
|
||||
<section class="page page--compact page--history" aria-labelledby="history-title">
|
||||
<p class="eyebrow">本机记录</p>
|
||||
<div class="history-title-row">
|
||||
<h1 id="history-title">问卦簿</h1>
|
||||
${hasRecords ? `<span class="source-badge">${state.historyRecords.length} 条</span>` : ""}
|
||||
</div>
|
||||
<p class="lede">回看当时看见了什么,而不是用旧结果替今天做决定。</p>
|
||||
|
||||
<div class="privacy-strip">
|
||||
${icons.lock}
|
||||
<span>记录只在本机,默认排除系统备份与设备迁移;你可随时删除。</span>
|
||||
</div>
|
||||
|
||||
${hasRecords ? `
|
||||
<ol class="history-list" data-history-list>
|
||||
${state.historyRecords.map(historyCard).join("")}
|
||||
</ol>` : `
|
||||
<div class="history-empty" data-history-empty>
|
||||
<span class="history-empty__mark" aria-hidden="true"><i></i><i></i></span>
|
||||
<h2>还没有保存的记录</h2>
|
||||
<p>${state.autoSaveHistory ? "完成一次起卦后,会默认把问题、卦象与后续解读保存在本机。" : "自动保存已经关闭;完成后仍可在结果页选择保存本次。"}</p>
|
||||
<button class="text-link" type="button" data-action="open-settings">管理保存设置</button>
|
||||
</div>`}
|
||||
|
||||
<div class="bottom-action">
|
||||
<button class="button button--primary" type="button" data-action="begin">
|
||||
${hasRecords ? "开始新的一问" : "开始第一次记录"} ${icons.arrow}
|
||||
</button>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function settingRow({ action, title, description, checked, disabled = false, detail = false }) {
|
||||
return `
|
||||
<label class="setting-row${detail ? " setting-row--detail" : ""}${disabled ? " setting-row--disabled" : ""}">
|
||||
<span class="setting-row__copy"><strong>${title}</strong><small>${description}</small></span>
|
||||
<input
|
||||
class="switch-control"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
data-action="${action}"
|
||||
${checked ? "checked" : ""}
|
||||
${disabled ? "disabled" : ""}
|
||||
/>
|
||||
</label>`;
|
||||
}
|
||||
|
||||
function settingsView() {
|
||||
const detailsDisabled = !state.autoSaveHistory;
|
||||
return `
|
||||
<section class="page page--compact page--settings" aria-labelledby="settings-title">
|
||||
<p class="eyebrow">本机与隐私</p>
|
||||
<h1 id="settings-title">保存设置</h1>
|
||||
<p class="lede">默认留住完整的反思过程;你可以全局关闭,也可以只调整保存内容。</p>
|
||||
|
||||
<div class="settings-boundary">
|
||||
${icons.shield}
|
||||
<div>
|
||||
<strong>只保存在 App 私有存储</strong>
|
||||
<p>历史不会主动上传,并默认排除系统备份、设备迁移与云同步。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="settings-card" aria-label="历史保存选项">
|
||||
${settingRow({
|
||||
action: "auto-save-toggle",
|
||||
title: "自动保存完整记录",
|
||||
description: state.autoSaveHistory ? "已开启 · 完成起卦后立即保存到本机" : "已关闭 · 结果页仍可手动保存本次",
|
||||
checked: state.autoSaveHistory
|
||||
})}
|
||||
<div class="settings-card__details${detailsDisabled ? " settings-card__details--disabled" : ""}">
|
||||
${settingRow({
|
||||
action: "save-question-setting",
|
||||
title: "保存问题原文",
|
||||
description: "与你写下的卦象背景一同回顾",
|
||||
checked: state.saveQuestion,
|
||||
disabled: detailsDisabled,
|
||||
detail: true
|
||||
})}
|
||||
${settingRow({
|
||||
action: "save-explanation-setting",
|
||||
title: "保存解读全文",
|
||||
description: "本地或 AI 解读会加入同一次记录",
|
||||
checked: state.saveExplanation,
|
||||
disabled: detailsDisabled,
|
||||
detail: true
|
||||
})}
|
||||
${settingRow({
|
||||
action: "save-action-setting",
|
||||
title: "保存行动记录",
|
||||
description: "保留“可以试的一小步”与完成状态",
|
||||
checked: state.saveAction,
|
||||
disabled: detailsDisabled,
|
||||
detail: true
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="settings-ai-note">这些开关只控制本机历史,不会允许或触发 AI。每次 AI 解读仍需要你主动选择并确认发送范围。</p>
|
||||
|
||||
<section class="settings-danger" aria-labelledby="history-management-title">
|
||||
<div>
|
||||
<h2 id="history-management-title">管理本机记录</h2>
|
||||
<p>当前原型会话中有 ${state.historyRecords.length} 条记录。</p>
|
||||
</div>
|
||||
<button class="button button--danger-quiet" type="button" data-action="open-clear-all" ${state.historyRecords.length ? "" : "disabled"}>清空全部问卦记录</button>
|
||||
</section>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function historyDetailView() {
|
||||
const record = state.historyRecords.find((item) => item.id === state.selectedRecordId);
|
||||
if (!record) return historyView();
|
||||
const data = historyRecordData(record);
|
||||
const hasMoving = data.movingIndexes.length > 0;
|
||||
return `
|
||||
<article class="page page--compact page--history-detail" aria-labelledby="history-detail-title">
|
||||
<header class="history-detail-header">
|
||||
<p class="eyebrow">${record.date}</p>
|
||||
<h1 id="history-detail-title">${recordRoute(record)}</h1>
|
||||
<p>保存的起卦快照</p>
|
||||
</header>
|
||||
|
||||
<div class="result-hero history-result-hero">
|
||||
<div class="result-kicker"><span>coin-v1 · 本地快照</span><span>${hasMoving ? `${data.movingIndexes.length} 个动爻` : "无动爻"}</span></div>
|
||||
<div class="hexagram-pair${hasMoving ? "" : " hexagram-pair--single"}">
|
||||
${hexagramFigure(record.lines, data.primary, true)}
|
||||
${hasMoving ? `<div class="transform-arrow" aria-label="变化为">之</div>${hexagramFigure(data.changedLines, data.changed, false)}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="content-section" aria-labelledby="saved-question-title">
|
||||
<div class="section-heading"><h2 id="saved-question-title">当时所问</h2><span class="source-badge">${record.question ? "已保存" : "未保存"}</span></div>
|
||||
<div class="content-card"><p>${escapeHtml(record.question ?? "这条记录没有保存问题原文。卦象结果和方法版本仍可独立复核。")}</p></div>
|
||||
</section>
|
||||
|
||||
<section class="content-section" aria-labelledby="saved-content-title">
|
||||
<div class="section-heading"><h2 id="saved-content-title">保存内容</h2></div>
|
||||
<dl class="record-facts">
|
||||
<div><dt>起卦结果</dt><dd>${data.primary.name}${hasMoving ? `之${data.changed.name}` : ""} · ${data.movingIndexes.length ? data.movingIndexes.map((index) => lineName(record.lines[index], index)).join("、") : "无动爻"}</dd></div>
|
||||
<div><dt>解释来源</dt><dd>${record.explanationSource ?? "未保存解释"}</dd></div>
|
||||
<div><dt>版本</dt><dd>coin-v1 · content-demo</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
${record.action ? `
|
||||
<section class="content-section" aria-labelledby="saved-action-title">
|
||||
<div class="small-step"><div class="small-step__label" id="saved-action-title">当时记下的一小步</div><p>${escapeHtml(record.action)}</p></div>
|
||||
</section>` : ""}
|
||||
|
||||
<div class="detail-danger-zone">
|
||||
<button class="button button--danger-quiet" type="button" data-action="delete-record">删除这条记录</button>
|
||||
<p>删除前会再次确认;原型刷新后恢复演示数据。</p>
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function questionView() {
|
||||
return `
|
||||
<section class="page" aria-labelledby="question-title">
|
||||
@@ -158,7 +509,7 @@
|
||||
data-question-input
|
||||
>${escapeHtml(state.question)}</textarea>
|
||||
<div class="field-meta">
|
||||
<span>只保存在本次原型会话中</span>
|
||||
<span>${state.autoSaveHistory ? "完成起卦后默认保存在本机" : "自动保存已关闭,本次可稍后手动保存"}</span>
|
||||
<span data-char-count>${state.question.length}/120</span>
|
||||
</div>
|
||||
|
||||
@@ -307,6 +658,39 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function currentSavePanel({ compact = false } = {}) {
|
||||
if (state.saved) {
|
||||
const explanationAttached = state.explanationGenerated && state.saveExplanation;
|
||||
const savedContext = state.saveQuestion && hasSavableQuestion() ? "问题、卦象" : "卦象";
|
||||
return `
|
||||
<div class="current-save-panel${compact ? " current-save-panel--compact" : ""}" data-current-save-status>
|
||||
<div class="current-save-panel__status">
|
||||
<span class="current-save-panel__icon">${icons.book}</span>
|
||||
<span>
|
||||
<strong>${explanationAttached ? "解读已加入本次记录" : state.currentSaveMode === "automatic" ? "已自动保存到本机" : "已保存到本机问卦簿"}</strong>
|
||||
<small>${explanationAttached ? `与${savedContext}归在同一次观照中` : `${savedContext}快照已进入问卦簿`}</small>
|
||||
</span>
|
||||
</div>
|
||||
<div class="current-save-panel__actions">
|
||||
<button class="text-link" type="button" data-action="open-history">查看问卦簿</button>
|
||||
<button class="text-link text-link--danger" type="button" data-action="remove-current-record">本次不保存</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="current-save-panel current-save-panel--off${compact ? " current-save-panel--compact" : ""}" data-current-save-status>
|
||||
<div class="current-save-panel__status">
|
||||
<span class="current-save-panel__icon">${icons.lock}</span>
|
||||
<span>
|
||||
<strong>本次未保存</strong>
|
||||
<small>${state.currentSessionOptedOut ? "你已选择不保留这次记录" : "自动保存已关闭;不会写入问卦簿"}</small>
|
||||
</span>
|
||||
</div>
|
||||
<button class="button button--secondary" type="button" data-action="save-current">保存本次</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function resultView() {
|
||||
const data = resultData();
|
||||
const copy = resultCopy(data);
|
||||
@@ -365,7 +749,10 @@
|
||||
</div>` : ""}
|
||||
|
||||
<hr class="divider" />
|
||||
<button class="text-link" type="button" data-action="restart">重新起一卦</button>
|
||||
<div class="result-secondary-actions">
|
||||
${currentSavePanel()}
|
||||
<button class="text-link" type="button" data-action="restart">重新起一卦</button>
|
||||
</div>
|
||||
|
||||
<div class="bottom-action">
|
||||
<button class="button button--primary" type="button" data-action="open-interpretation">
|
||||
@@ -430,11 +817,58 @@
|
||||
<p class="safety-note">这是一种文化反思与文字整理,不是预测、诊断或替代你的判断。涉及医疗、法律、财务或人身安全时,请寻求相应专业支持。</p>
|
||||
|
||||
<div class="bottom-action">
|
||||
<button class="button button--secondary" type="button" data-action="back-to-result">回到起卦结果</button>
|
||||
${currentSavePanel({ compact: true })}
|
||||
<button class="button button--quiet" type="button" data-action="back-to-result">回到起卦结果</button>
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function deleteRecordModal() {
|
||||
return `
|
||||
<div class="modal-backdrop" data-action="dismiss-modal">
|
||||
<section class="modal-sheet modal-sheet--dialog" role="alertdialog" aria-modal="true" aria-labelledby="delete-modal-title" aria-describedby="delete-modal-description" data-modal-sheet>
|
||||
<div class="modal-handle" aria-hidden="true"></div>
|
||||
<header class="modal-header">
|
||||
<div>
|
||||
<h2 id="delete-modal-title">删除这条记录?</h2>
|
||||
<p id="delete-modal-description">卦象、问题、已保存解读和行动记录会一并从本机删除。</p>
|
||||
</div>
|
||||
<button class="icon-button" type="button" data-action="close-modal" aria-label="关闭"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
|
||||
</header>
|
||||
<div class="notice notice--danger">
|
||||
${icons.shield}<p>原型使用演示数据;正式版删除不可恢复,因此必须在这里再次确认。</p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="button button--danger" type="button" data-action="confirm-delete">确认删除</button>
|
||||
<button class="button button--secondary" type="button" data-action="close-modal">取消</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function clearAllModal() {
|
||||
return `
|
||||
<div class="modal-backdrop" data-action="dismiss-modal">
|
||||
<section class="modal-sheet modal-sheet--dialog" role="alertdialog" aria-modal="true" aria-labelledby="clear-modal-title" aria-describedby="clear-modal-description" data-modal-sheet>
|
||||
<div class="modal-handle" aria-hidden="true"></div>
|
||||
<header class="modal-header">
|
||||
<div>
|
||||
<h2 id="clear-modal-title">清空全部记录?</h2>
|
||||
<p id="clear-modal-description">当前 ${state.historyRecords.length} 条记录的卦象、问题、解读和行动内容都会从本机删除。</p>
|
||||
</div>
|
||||
<button class="icon-button" type="button" data-action="close-modal" aria-label="关闭"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
|
||||
</header>
|
||||
<div class="notice notice--danger">
|
||||
${icons.shield}<p>这项操作不可恢复,但不会改变你的自动保存设置。</p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="button button--danger" type="button" data-action="confirm-clear-all">确认清空全部</button>
|
||||
<button class="button button--secondary" type="button" data-action="close-modal">取消</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function interpretationModal() {
|
||||
return `
|
||||
<div class="modal-backdrop" data-action="dismiss-modal">
|
||||
@@ -532,6 +966,8 @@
|
||||
if (state.modal === "interpretation") modalRoot.innerHTML = interpretationModal();
|
||||
else if (state.modal === "consent") modalRoot.innerHTML = consentModal();
|
||||
else if (state.modal === "method") modalRoot.innerHTML = methodModal();
|
||||
else if (state.modal === "delete") modalRoot.innerHTML = deleteRecordModal();
|
||||
else if (state.modal === "clear-all") modalRoot.innerHTML = clearAllModal();
|
||||
else modalRoot.innerHTML = "";
|
||||
|
||||
document.body.style.overflow = state.modal ? "hidden" : "";
|
||||
@@ -544,13 +980,17 @@
|
||||
|
||||
function render(options = {}) {
|
||||
if (state.screen === "welcome") screen.innerHTML = welcomeView();
|
||||
else if (state.screen === "home") screen.innerHTML = homeView();
|
||||
else if (state.screen === "history") screen.innerHTML = historyView();
|
||||
else if (state.screen === "history-detail") screen.innerHTML = historyDetailView();
|
||||
else if (state.screen === "settings") screen.innerHTML = settingsView();
|
||||
else if (state.screen === "question") screen.innerHTML = questionView();
|
||||
else if (state.screen === "casting") screen.innerHTML = castingView();
|
||||
else if (state.screen === "result") screen.innerHTML = resultView();
|
||||
else if (state.screen === "loading") screen.innerHTML = loadingView();
|
||||
else if (state.screen === "explanation") screen.innerHTML = explanationView();
|
||||
|
||||
backButton.hidden = state.screen === "welcome";
|
||||
backButton.hidden = state.screen === "welcome" || state.screen === "home";
|
||||
renderModal();
|
||||
|
||||
if (!options.preserveFocus) {
|
||||
@@ -577,7 +1017,10 @@
|
||||
closeModal();
|
||||
return;
|
||||
}
|
||||
if (state.screen === "question") state.screen = "welcome";
|
||||
if (state.screen === "question") state.screen = state.questionOrigin;
|
||||
else if (state.screen === "history") state.screen = "home";
|
||||
else if (state.screen === "history-detail") state.screen = "history";
|
||||
else if (state.screen === "settings") state.screen = state.settingsOrigin;
|
||||
else if (state.screen === "casting") state.screen = "question";
|
||||
else if (state.screen === "result") state.screen = "question";
|
||||
else if (state.screen === "loading" || state.screen === "explanation") state.screen = "result";
|
||||
@@ -591,6 +1034,8 @@
|
||||
render();
|
||||
window.setTimeout(() => {
|
||||
if (state.screen !== "loading") return;
|
||||
state.explanationGenerated = true;
|
||||
attachExplanationToCurrentSession();
|
||||
state.screen = "explanation";
|
||||
render();
|
||||
}, 900);
|
||||
@@ -605,10 +1050,21 @@
|
||||
});
|
||||
|
||||
document.addEventListener("change", (event) => {
|
||||
if (!event.target.matches("[data-action='consent-toggle']")) return;
|
||||
state.consent = event.target.checked;
|
||||
const confirm = document.querySelector("[data-action='confirm-ai']");
|
||||
if (confirm) confirm.disabled = !state.consent;
|
||||
if (event.target.matches("[data-action='consent-toggle']")) {
|
||||
state.consent = event.target.checked;
|
||||
const confirm = document.querySelector("[data-action='confirm-ai']");
|
||||
if (confirm) confirm.disabled = !state.consent;
|
||||
} else if (event.target.matches("[data-action='auto-save-toggle']")) {
|
||||
state.autoSaveHistory = event.target.checked;
|
||||
render({ preserveFocus: true });
|
||||
document.querySelector("[data-action='auto-save-toggle']")?.focus();
|
||||
} else if (event.target.matches("[data-action='save-question-setting']")) {
|
||||
state.saveQuestion = event.target.checked;
|
||||
} else if (event.target.matches("[data-action='save-explanation-setting']")) {
|
||||
state.saveExplanation = event.target.checked;
|
||||
} else if (event.target.matches("[data-action='save-action-setting']")) {
|
||||
state.saveAction = event.target.checked;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
@@ -620,9 +1076,22 @@
|
||||
|
||||
if (action === "back") goBack();
|
||||
else if (action === "begin") {
|
||||
state.questionOrigin = state.screen;
|
||||
state.screen = "question";
|
||||
render();
|
||||
} else if (action === "open-history") {
|
||||
state.screen = "history";
|
||||
render();
|
||||
} else if (action === "open-settings") {
|
||||
state.settingsOrigin = state.screen === "settings" ? "home" : state.screen;
|
||||
state.screen = "settings";
|
||||
render();
|
||||
} else if (action === "open-history-detail") {
|
||||
state.selectedRecordId = actionTarget.dataset.recordId;
|
||||
state.screen = "history-detail";
|
||||
render();
|
||||
} else if (action === "skip-question") {
|
||||
prepareNewSession();
|
||||
state.question = "未记录具体问题(仅作自我观察)";
|
||||
state.lines = [];
|
||||
state.coins = [null, null, null];
|
||||
@@ -630,6 +1099,7 @@
|
||||
render();
|
||||
} else if (action === "start-casting") {
|
||||
if (!state.question.trim()) return;
|
||||
prepareNewSession();
|
||||
state.lines = [];
|
||||
state.coins = [null, null, null];
|
||||
state.screen = "casting";
|
||||
@@ -645,6 +1115,7 @@
|
||||
state.lines.push(state.coins.reduce((total, value) => total + value, 0));
|
||||
state.coins = [null, null, null];
|
||||
if (state.lines.length === 6) {
|
||||
if (state.autoSaveHistory) persistCurrentSession("automatic");
|
||||
state.screen = "result";
|
||||
}
|
||||
render();
|
||||
@@ -663,6 +1134,8 @@
|
||||
} else if (action === "choose-local") {
|
||||
state.modal = null;
|
||||
state.explanationSource = "local";
|
||||
state.explanationGenerated = true;
|
||||
attachExplanationToCurrentSession();
|
||||
state.screen = "explanation";
|
||||
render();
|
||||
} else if (action === "confirm-ai") {
|
||||
@@ -673,12 +1146,56 @@
|
||||
state.modal = "consent";
|
||||
state.consent = false;
|
||||
renderModal();
|
||||
} else if (action === "save-current") {
|
||||
persistCurrentSession();
|
||||
render();
|
||||
showToast("本次记录已保存到本机");
|
||||
} else if (action === "remove-current-record") {
|
||||
if (!state.saved) return;
|
||||
state.selectedRecordId = state.currentRecordId;
|
||||
state.deleteReturnScreen = state.screen;
|
||||
state.modal = "delete";
|
||||
renderModal();
|
||||
} else if (action === "delete-record") {
|
||||
state.deleteReturnScreen = "history";
|
||||
state.modal = "delete";
|
||||
renderModal();
|
||||
} else if (action === "confirm-delete") {
|
||||
const deletedCurrentSession = state.selectedRecordId === state.currentRecordId;
|
||||
state.historyRecords = state.historyRecords.filter((record) => record.id !== state.selectedRecordId);
|
||||
state.selectedRecordId = null;
|
||||
state.modal = null;
|
||||
if (deletedCurrentSession) {
|
||||
state.saved = false;
|
||||
state.currentSessionOptedOut = true;
|
||||
state.currentSaveMode = null;
|
||||
}
|
||||
state.screen = state.deleteReturnScreen;
|
||||
render();
|
||||
showToast("记录已从本机删除");
|
||||
} else if (action === "open-clear-all") {
|
||||
if (!state.historyRecords.length) return;
|
||||
state.modal = "clear-all";
|
||||
renderModal();
|
||||
} else if (action === "confirm-clear-all") {
|
||||
const includedCurrentSession = Boolean(currentSessionRecord());
|
||||
state.historyRecords = [];
|
||||
state.selectedRecordId = null;
|
||||
state.modal = null;
|
||||
if (includedCurrentSession) {
|
||||
state.saved = false;
|
||||
state.currentSessionOptedOut = true;
|
||||
state.currentSaveMode = null;
|
||||
}
|
||||
render();
|
||||
showToast("已清空全部本机记录");
|
||||
} else if (action === "restart") {
|
||||
state.question = "";
|
||||
state.lines = [];
|
||||
state.coins = [null, null, null];
|
||||
state.aiError = false;
|
||||
state.screen = "welcome";
|
||||
prepareNewSession();
|
||||
state.questionOrigin = state.historyRecords.length ? "home" : "welcome";
|
||||
state.screen = "question";
|
||||
render();
|
||||
} else if (action === "back-to-result") {
|
||||
state.screen = "result";
|
||||
@@ -705,6 +1222,22 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (["home", "home-empty", "history", "history-empty", "history-detail", "delete", "settings", "settings-off", "clear-all"].includes(view)) {
|
||||
state.historyRecords = view === "home-empty" || view === "history-empty" ? [] : sampleHistory();
|
||||
state.screen = view.startsWith("home")
|
||||
? "home"
|
||||
: view === "history-detail" || view === "delete"
|
||||
? "history-detail"
|
||||
: view === "settings" || view === "settings-off" || view === "clear-all"
|
||||
? "settings"
|
||||
: "history";
|
||||
state.selectedRecordId = state.historyRecords[0]?.id ?? null;
|
||||
if (view === "settings-off") state.autoSaveHistory = false;
|
||||
if (view === "delete") state.modal = "delete";
|
||||
if (view === "clear-all") state.modal = "clear-all";
|
||||
return;
|
||||
}
|
||||
|
||||
state.question = SAMPLE_QUESTION;
|
||||
if (view === "casting") {
|
||||
state.screen = "casting";
|
||||
@@ -715,14 +1248,20 @@
|
||||
|
||||
state.lines = view === "static" ? [7, 7, 7, 7, 7, 7] : [...SAMPLE_LINES];
|
||||
state.screen = "result";
|
||||
if (view === "result-not-saved") state.autoSaveHistory = false;
|
||||
if (state.autoSaveHistory) persistCurrentSession("automatic");
|
||||
|
||||
if (view === "consent") state.modal = "consent";
|
||||
else if (view === "explanation") {
|
||||
state.screen = "explanation";
|
||||
state.explanationSource = "ai";
|
||||
state.explanationGenerated = true;
|
||||
attachExplanationToCurrentSession();
|
||||
} else if (view === "local") {
|
||||
state.screen = "explanation";
|
||||
state.explanationSource = "local";
|
||||
state.explanationGenerated = true;
|
||||
attachExplanationToCurrentSession();
|
||||
} else if (view === "error") {
|
||||
state.aiError = true;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,17 @@ const cases = [
|
||||
{ view: "static", file: "05-result-static.png", fullPage: true },
|
||||
{ view: "consent", file: "06-ai-consent.png", fullPage: false },
|
||||
{ view: "explanation", file: "07-ai-explanation.png", fullPage: true },
|
||||
{ view: "error", file: "08-ai-error.png", fullPage: true }
|
||||
{ view: "error", file: "08-ai-error.png", fullPage: true },
|
||||
{ view: "home", file: "09-home-returning.png", fullPage: true },
|
||||
{ view: "home-empty", file: "10-home-empty.png", fullPage: true },
|
||||
{ view: "history", file: "11-history-list.png", fullPage: true },
|
||||
{ view: "history-empty", file: "12-history-empty.png", fullPage: true },
|
||||
{ view: "history-detail", file: "13-history-detail.png", fullPage: true },
|
||||
{ view: "settings", file: "14-settings-default.png", fullPage: true },
|
||||
{ view: "delete", file: "15-delete-confirm.png", fullPage: false },
|
||||
{ view: "settings-off", file: "16-settings-off.png", fullPage: true },
|
||||
{ view: "result-not-saved", file: "17-result-not-saved.png", fullPage: true },
|
||||
{ view: "clear-all", file: "18-clear-all-confirm.png", fullPage: false }
|
||||
];
|
||||
|
||||
function findChrome() {
|
||||
@@ -206,6 +216,26 @@ async function auditView(client, view) {
|
||||
})()`);
|
||||
}
|
||||
|
||||
async function auditLayout(client, { name, width, height, view }) {
|
||||
await client.send("Emulation.setDeviceMetricsOverride", {
|
||||
width,
|
||||
height,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false,
|
||||
screenWidth: width,
|
||||
screenHeight: height
|
||||
});
|
||||
await navigate(client, view);
|
||||
return evaluate(client, `(() => ({
|
||||
name: ${JSON.stringify(name)},
|
||||
view: ${JSON.stringify(view)},
|
||||
viewport: { width: innerWidth, height: innerHeight },
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
horizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||
mainReachable: Boolean(document.querySelector('#screen'))
|
||||
}))()`);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const health = await fetch(baseUrl);
|
||||
if (!health.ok) {
|
||||
@@ -265,6 +295,18 @@ async function run() {
|
||||
const screenshots = [];
|
||||
for (const item of cases) screenshots.push(await capture(client, item));
|
||||
|
||||
await navigate(client, "casting");
|
||||
for (let remainingRound = 0; remainingRound < 4; remainingRound += 1) {
|
||||
for (let coinIndex = 0; coinIndex < 3; coinIndex += 1) {
|
||||
await evaluate(client, `document.querySelector('[data-action="toggle-coin"][data-index="${coinIndex}"]')?.click()`);
|
||||
}
|
||||
await evaluate(client, "document.querySelector('[data-action=\"confirm-line\"]')?.click()");
|
||||
}
|
||||
const completedCastingSave = await evaluate(client, `(() => ({
|
||||
resultVisible: Boolean(document.querySelector('#result-title')),
|
||||
status: document.querySelector('[data-current-save-status]')?.textContent.replace(/\\s+/g, " ").trim()
|
||||
}))()`);
|
||||
|
||||
await navigate(client, "result");
|
||||
const movingResult = await evaluate(client, `(() => ({
|
||||
names: [...document.querySelectorAll(".hexagram-name")].map((node) => node.textContent.trim()),
|
||||
@@ -289,28 +331,158 @@ async function run() {
|
||||
await evaluate(client, "document.querySelector('[data-action=\"confirm-ai\"]')?.click()");
|
||||
await delay(1050);
|
||||
const explanationSource = await evaluate(client, "document.querySelector('.source-badge')?.textContent.trim()");
|
||||
const explanationSaveStatus = await evaluate(client, "document.querySelector('[data-current-save-status]')?.textContent.replace(/\\s+/g, ' ').trim()");
|
||||
|
||||
await navigate(client, "home");
|
||||
const homeState = await evaluate(client, `(() => ({
|
||||
primaryActions: document.querySelectorAll('[data-action="begin"]').length,
|
||||
recentRoute: document.querySelector('.recent-card__route')?.textContent.replace(/\\s+/g, " ").trim(),
|
||||
historyEntry: document.querySelectorAll('[data-action="open-history"]').length,
|
||||
settingsEntry: document.querySelectorAll('[data-action="open-settings"]').length,
|
||||
localNotice: document.querySelector('[data-local-data-notice]')?.textContent.replace(/\\s+/g, " ").trim(),
|
||||
bottomNavigation: document.querySelectorAll('nav, [role="navigation"]').length
|
||||
}))()`);
|
||||
await evaluate(client, "document.querySelector('[data-action=\"begin\"]')?.click()");
|
||||
const homeForwardTitle = await evaluate(client, "document.querySelector('#question-title')?.textContent.trim()");
|
||||
await evaluate(client, "document.querySelector('[data-action=\"back\"]')?.click()");
|
||||
const homeBackTitle = await evaluate(client, "document.querySelector('#home-title')?.textContent.trim()");
|
||||
|
||||
await navigate(client, "history");
|
||||
const historyState = await evaluate(client, `(() => ({
|
||||
count: document.querySelectorAll('.history-card').length,
|
||||
firstRoute: document.querySelector('.history-card__route')?.textContent.replace(/\\s+/g, " ").trim()
|
||||
}))()`);
|
||||
|
||||
await navigate(client, "history-empty");
|
||||
const historyEmptyState = await evaluate(client, `(() => ({
|
||||
hasEmptyGuidance: Boolean(document.querySelector('[data-history-empty]')),
|
||||
hasPrimaryAction: Boolean(document.querySelector('[data-action="begin"]')),
|
||||
hasSettingsEntry: Boolean(document.querySelector('[data-action="open-settings"]'))
|
||||
}))()`);
|
||||
|
||||
await navigate(client, "settings");
|
||||
const settingsDefaults = await evaluate(client, `(() => ({
|
||||
autoSave: document.querySelector('[data-action="auto-save-toggle"]')?.checked,
|
||||
question: document.querySelector('[data-action="save-question-setting"]')?.checked,
|
||||
explanation: document.querySelector('[data-action="save-explanation-setting"]')?.checked,
|
||||
action: document.querySelector('[data-action="save-action-setting"]')?.checked,
|
||||
detailDisabled: [...document.querySelectorAll('.settings-card__details input')].some((input) => input.disabled),
|
||||
boundary: document.querySelector('.settings-boundary')?.textContent.replace(/\\s+/g, " ").trim()
|
||||
}))()`);
|
||||
await evaluate(client, "document.querySelector('[data-action=\"auto-save-toggle\"]')?.click()");
|
||||
await delay(100);
|
||||
const settingsDisabled = await evaluate(client, `(() => ({
|
||||
autoSave: document.querySelector('[data-action="auto-save-toggle"]')?.checked,
|
||||
detailsDisabled: [...document.querySelectorAll('.settings-card__details input')].every((input) => input.disabled),
|
||||
retainedValues: [...document.querySelectorAll('.settings-card__details input')].every((input) => input.checked)
|
||||
}))()`);
|
||||
|
||||
await navigate(client, "result");
|
||||
const autoSaveBefore = await evaluate(client, `(() => ({
|
||||
status: document.querySelector('[data-current-save-status]')?.textContent.replace(/\\s+/g, " ").trim(),
|
||||
canOptOut: Boolean(document.querySelector('[data-action="remove-current-record"]'))
|
||||
}))()`);
|
||||
await evaluate(client, "document.querySelector('[data-action=\"remove-current-record\"]')?.click()");
|
||||
const optOutDialogVisible = await evaluate(client, "Boolean(document.querySelector('[role=\"alertdialog\"]'))");
|
||||
await evaluate(client, "document.querySelector('[data-action=\"confirm-delete\"]')?.click()");
|
||||
await delay(100);
|
||||
const afterOptOut = await evaluate(client, `(() => ({
|
||||
status: document.querySelector('[data-current-save-status]')?.textContent.replace(/\\s+/g, " ").trim(),
|
||||
canSave: Boolean(document.querySelector('[data-action="save-current"]'))
|
||||
}))()`);
|
||||
await evaluate(client, "document.querySelector('[data-action=\"save-current\"]')?.click()");
|
||||
await delay(100);
|
||||
const afterManualSave = await evaluate(client, "document.querySelector('[data-current-save-status]')?.textContent.replace(/\\s+/g, ' ').trim()");
|
||||
|
||||
await navigate(client, "result-not-saved");
|
||||
const autoSaveOffResult = await evaluate(client, `(() => ({
|
||||
status: document.querySelector('[data-current-save-status]')?.textContent.replace(/\\s+/g, " ").trim(),
|
||||
canSave: Boolean(document.querySelector('[data-action="save-current"]'))
|
||||
}))()`);
|
||||
|
||||
await navigate(client, "clear-all");
|
||||
const clearBefore = await evaluate(client, `(() => ({
|
||||
dialogVisible: Boolean(document.querySelector('[role="alertdialog"]')),
|
||||
countText: document.querySelector('#clear-modal-description')?.textContent.trim()
|
||||
}))()`);
|
||||
await evaluate(client, "document.querySelector('[data-action=\"confirm-clear-all\"]')?.click()");
|
||||
await delay(100);
|
||||
const clearAfter = await evaluate(client, `(() => ({
|
||||
settingsVisible: document.querySelector('#settings-title')?.textContent.trim(),
|
||||
recordCount: document.querySelector('.settings-danger p')?.textContent.trim(),
|
||||
clearDisabled: document.querySelector('[data-action="open-clear-all"]')?.disabled,
|
||||
autoSaveRetained: document.querySelector('[data-action="auto-save-toggle"]')?.checked,
|
||||
modalClosed: !document.querySelector('[role="alertdialog"]')
|
||||
}))()`);
|
||||
|
||||
await navigate(client, "delete");
|
||||
const deleteBefore = await evaluate(client, `(() => ({
|
||||
dialogVisible: Boolean(document.querySelector('[role="alertdialog"]')),
|
||||
detailVisible: Boolean(document.querySelector('#history-detail-title'))
|
||||
}))()`);
|
||||
await evaluate(client, "document.querySelector('[data-action=\"confirm-delete\"]')?.click()");
|
||||
await delay(100);
|
||||
const deleteAfter = await evaluate(client, `(() => ({
|
||||
onHistory: document.querySelector('#history-title')?.textContent.trim(),
|
||||
historyCount: document.querySelectorAll('.history-card').length,
|
||||
modalClosed: !document.querySelector('[role="alertdialog"]')
|
||||
}))()`);
|
||||
|
||||
const accessibility = [];
|
||||
for (const item of cases) accessibility.push(await auditView(client, item.view));
|
||||
|
||||
const layoutAudits = [];
|
||||
for (const layout of [
|
||||
{ name: "small-phone", width: 375, height: 812, view: "home" },
|
||||
{ name: "large-phone", width: 430, height: 932, view: "history" },
|
||||
{ name: "large-phone-settings", width: 430, height: 932, view: "settings" },
|
||||
{ name: "phone-landscape", width: 792, height: 360, view: "history-detail" }
|
||||
]) {
|
||||
layoutAudits.push(await auditLayout(client, layout));
|
||||
}
|
||||
|
||||
await client.send("Emulation.setEmulatedMedia", {
|
||||
features: [{ name: "prefers-reduced-motion", value: "reduce" }]
|
||||
});
|
||||
await navigate(client, "home");
|
||||
const reducedMotion = await evaluate(client, `(() => {
|
||||
const page = document.querySelector('.page');
|
||||
const duration = Number.parseFloat(getComputedStyle(page).animationDuration) || 0;
|
||||
return { animationDurationSeconds: duration, reduced: duration <= 0.001 };
|
||||
})()`);
|
||||
await client.send("Emulation.setEmulatedMedia", { features: [] });
|
||||
|
||||
const report = {
|
||||
chrome,
|
||||
baseUrl,
|
||||
screenshots,
|
||||
completedCastingSave,
|
||||
movingResult,
|
||||
staticResult,
|
||||
consentGate: {
|
||||
disabledBeforeConsent: consentBefore,
|
||||
disabledAfterConsent: consentAfter,
|
||||
explanationSource
|
||||
explanationSource,
|
||||
explanationSaveStatus
|
||||
},
|
||||
homeState: { ...homeState, forwardTitle: homeForwardTitle, backTitle: homeBackTitle },
|
||||
historyState,
|
||||
historyEmptyState,
|
||||
settingsFlow: { defaults: settingsDefaults, disabled: settingsDisabled },
|
||||
autoSaveFlow: { before: autoSaveBefore, optOutDialogVisible, afterOptOut, afterManualSave, autoSaveOffResult },
|
||||
clearAllFlow: { before: clearBefore, after: clearAfter },
|
||||
deleteFlow: { before: deleteBefore, after: deleteAfter },
|
||||
consoleErrors,
|
||||
runtimeErrors,
|
||||
externalRequests: [...new Set(externalRequests)],
|
||||
accessibility
|
||||
accessibility,
|
||||
layoutAudits,
|
||||
reducedMotion
|
||||
};
|
||||
|
||||
const failed =
|
||||
completedCastingSave.resultVisible !== true ||
|
||||
!completedCastingSave.status?.includes("已自动保存到本机") ||
|
||||
movingResult.names.join(",") !== "复,坤" ||
|
||||
movingResult.numbers.join(",") !== "第 24 卦,第 2 卦" ||
|
||||
!movingResult.moving.includes("1 个动爻") ||
|
||||
@@ -319,12 +491,59 @@ async function run() {
|
||||
consentBefore !== true ||
|
||||
consentAfter !== false ||
|
||||
explanationSource !== "AI 生成" ||
|
||||
!explanationSaveStatus?.includes("解读已加入本次记录") ||
|
||||
homeState.primaryActions !== 1 ||
|
||||
homeState.recentRoute !== "复 之 坤" ||
|
||||
homeState.historyEntry < 1 ||
|
||||
homeState.settingsEntry !== 1 ||
|
||||
!homeState.localNotice?.includes("起卦与历史默认保存在本机,不主动上传") ||
|
||||
!homeState.localNotice?.includes("只有你选择 AI 解读时,本次所需内容才会发送") ||
|
||||
homeState.bottomNavigation !== 0 ||
|
||||
homeForwardTitle !== "你想看清什么?" ||
|
||||
homeBackTitle !== "此刻,有什么想安静看清?" ||
|
||||
historyState.count !== 3 ||
|
||||
historyState.firstRoute !== "复 之 坤" ||
|
||||
historyEmptyState.hasEmptyGuidance !== true ||
|
||||
historyEmptyState.hasPrimaryAction !== true ||
|
||||
historyEmptyState.hasSettingsEntry !== true ||
|
||||
settingsDefaults.autoSave !== true ||
|
||||
settingsDefaults.question !== true ||
|
||||
settingsDefaults.explanation !== true ||
|
||||
settingsDefaults.action !== true ||
|
||||
settingsDefaults.detailDisabled !== false ||
|
||||
!settingsDefaults.boundary?.includes("默认排除系统备份、设备迁移与云同步") ||
|
||||
settingsDisabled.autoSave !== false ||
|
||||
settingsDisabled.detailsDisabled !== true ||
|
||||
settingsDisabled.retainedValues !== true ||
|
||||
!autoSaveBefore.status?.includes("已自动保存到本机") ||
|
||||
autoSaveBefore.canOptOut !== true ||
|
||||
optOutDialogVisible !== true ||
|
||||
!afterOptOut.status?.includes("本次未保存") ||
|
||||
!afterOptOut.status?.includes("你已选择不保留这次记录") ||
|
||||
afterOptOut.canSave !== true ||
|
||||
!afterManualSave?.includes("已保存到本机问卦簿") ||
|
||||
!autoSaveOffResult.status?.includes("自动保存已关闭") ||
|
||||
autoSaveOffResult.canSave !== true ||
|
||||
clearBefore.dialogVisible !== true ||
|
||||
!clearBefore.countText?.includes("当前 3 条记录") ||
|
||||
clearAfter.settingsVisible !== "保存设置" ||
|
||||
!clearAfter.recordCount?.includes("0 条记录") ||
|
||||
clearAfter.clearDisabled !== true ||
|
||||
clearAfter.autoSaveRetained !== true ||
|
||||
clearAfter.modalClosed !== true ||
|
||||
deleteBefore.dialogVisible !== true ||
|
||||
deleteBefore.detailVisible !== true ||
|
||||
deleteAfter.onHistory !== "问卦簿" ||
|
||||
deleteAfter.historyCount !== 2 ||
|
||||
deleteAfter.modalClosed !== true ||
|
||||
consoleErrors.length > 0 ||
|
||||
runtimeErrors.length > 0 ||
|
||||
externalRequests.length > 0 ||
|
||||
accessibility.some((item) =>
|
||||
item.unnamedButtons > 0 || item.touchIssues.length > 0 || item.smallCheckboxLabels > 0
|
||||
);
|
||||
) ||
|
||||
layoutAudits.some((item) => item.horizontalOverflow || !item.mainReachable) ||
|
||||
reducedMotion.reduced !== true;
|
||||
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (failed) process.exitCode = 1;
|
||||
|
||||
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 75 KiB |
@@ -202,6 +202,583 @@ a:focus-visible {
|
||||
padding-top: clamp(36px, 9vh, 72px);
|
||||
}
|
||||
|
||||
.page--home {
|
||||
padding-top: 28px;
|
||||
padding-bottom: calc(32px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.home-intro h1 {
|
||||
max-width: 10em;
|
||||
margin-bottom: 12px;
|
||||
font-size: clamp(31px, 9vw, 38px);
|
||||
}
|
||||
|
||||
.home-intro .lede {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.home-primary {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.home-shelf {
|
||||
margin-top: 32px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--outline-soft);
|
||||
}
|
||||
|
||||
.home-shelf__heading {
|
||||
margin-bottom: 13px;
|
||||
}
|
||||
|
||||
.home-shelf__heading h2 {
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
.home-shelf__heading .text-link {
|
||||
min-height: 48px;
|
||||
padding-right: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.recent-card,
|
||||
.home-empty-card,
|
||||
.history-card {
|
||||
width: 100%;
|
||||
color: var(--ink);
|
||||
background: rgb(255 253 247 / 76%);
|
||||
border: 1px solid var(--outline-soft);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 180ms ease, background-color 180ms ease, transform 180ms ease, box-shadow 180ms ease;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.recent-card:hover,
|
||||
.home-empty-card:hover,
|
||||
.history-card:hover {
|
||||
background: var(--surface);
|
||||
border-color: var(--outline);
|
||||
box-shadow: var(--shadow-soft);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.recent-card:active,
|
||||
.home-empty-card:active,
|
||||
.history-card:active {
|
||||
transform: scale(.995);
|
||||
}
|
||||
|
||||
.recent-card {
|
||||
display: grid;
|
||||
min-height: 154px;
|
||||
padding: 15px 16px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.recent-card__top,
|
||||
.recent-card__meta,
|
||||
.history-card__top,
|
||||
.history-card__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.recent-card__top,
|
||||
.history-card__top {
|
||||
color: var(--ink-faint);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.recent-card__route,
|
||||
.history-card__route {
|
||||
font-family: var(--serif);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.recent-card__route span,
|
||||
.history-card__route span {
|
||||
margin: 0 4px;
|
||||
color: var(--outline);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.recent-card__question,
|
||||
.history-card__question {
|
||||
overflow: hidden;
|
||||
color: var(--ink-soft);
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.recent-card__meta,
|
||||
.history-card__footer {
|
||||
color: var(--ink-faint);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.recent-card__meta svg,
|
||||
.history-card__footer svg,
|
||||
.home-empty-card > svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 18px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1.7;
|
||||
}
|
||||
|
||||
.recent-card__meta span:nth-child(2) {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.home-empty-card {
|
||||
display: grid;
|
||||
min-height: 86px;
|
||||
padding: 14px;
|
||||
grid-template-columns: 44px 1fr 20px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.home-empty-card__icon {
|
||||
display: grid;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
color: var(--ochre);
|
||||
background: var(--surface-muted);
|
||||
border-radius: 50%;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.home-empty-card__icon svg,
|
||||
.button > svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1.7;
|
||||
}
|
||||
|
||||
.home-empty-card strong,
|
||||
.home-empty-card small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.home-empty-card small {
|
||||
margin-top: 2px;
|
||||
color: var(--ink-faint);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.local-data-card {
|
||||
display: grid;
|
||||
margin-top: 14px;
|
||||
padding: 14px;
|
||||
grid-template-columns: 36px 1fr;
|
||||
align-items: start;
|
||||
gap: 11px;
|
||||
color: var(--ink-soft);
|
||||
background: #edf1e9;
|
||||
border: 1px solid #d0dacb;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.local-data-card__icon {
|
||||
display: grid;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: var(--success);
|
||||
background: rgb(255 255 255 / 64%);
|
||||
border-radius: 50%;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.local-data-card__icon svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1.7;
|
||||
}
|
||||
|
||||
.local-data-card strong {
|
||||
display: block;
|
||||
color: #304c39;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.local-data-card p {
|
||||
margin: 3px 0 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.local-data-card .text-link {
|
||||
margin-bottom: -8px;
|
||||
color: #405e49;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.page--history {
|
||||
padding-top: 28px;
|
||||
}
|
||||
|
||||
.history-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.history-title-row h1 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.privacy-strip {
|
||||
display: flex;
|
||||
margin: 20px 0;
|
||||
padding: 12px 13px;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
color: var(--ink-soft);
|
||||
background: rgb(255 253 247 / 62%);
|
||||
border: 1px solid var(--outline-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.privacy-strip svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 18px;
|
||||
fill: none;
|
||||
stroke: var(--ochre);
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1.7;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
display: grid;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
gap: 12px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.history-card {
|
||||
display: grid;
|
||||
min-height: 132px;
|
||||
padding: 15px 16px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.history-card__route {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.history-card__footer {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.history-card__footer span {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.history-empty {
|
||||
display: grid;
|
||||
min-height: 330px;
|
||||
padding: 34px 20px;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
color: var(--ink-soft);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.history-empty__mark {
|
||||
display: grid;
|
||||
width: 92px;
|
||||
height: 92px;
|
||||
margin-bottom: 22px;
|
||||
place-content: center;
|
||||
gap: 11px;
|
||||
border: 1px solid var(--outline-soft);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.history-empty__mark i {
|
||||
display: block;
|
||||
width: 44px;
|
||||
height: 5px;
|
||||
background: var(--ink);
|
||||
}
|
||||
|
||||
.history-empty__mark i:last-child {
|
||||
background: linear-gradient(90deg, var(--ink) 0 40%, transparent 40% 60%, var(--ink) 60%);
|
||||
}
|
||||
|
||||
.history-empty h2 {
|
||||
margin-bottom: 8px;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.history-empty p {
|
||||
max-width: 23em;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.page--settings {
|
||||
padding-top: 28px;
|
||||
}
|
||||
|
||||
.page--settings > h1 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.settings-boundary {
|
||||
display: grid;
|
||||
margin: 22px 0 16px;
|
||||
padding: 15px;
|
||||
grid-template-columns: 40px 1fr;
|
||||
align-items: start;
|
||||
gap: 12px;
|
||||
color: var(--ink-soft);
|
||||
background: #e9efe7;
|
||||
border: 1px solid #cfdbcc;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.settings-boundary > svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin: 5px auto 0;
|
||||
fill: none;
|
||||
stroke: var(--success);
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1.7;
|
||||
}
|
||||
|
||||
.settings-boundary strong,
|
||||
.settings-boundary p {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.settings-boundary p {
|
||||
margin: 3px 0 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
overflow: hidden;
|
||||
background: rgb(255 253 247 / 80%);
|
||||
border: 1px solid var(--outline-soft);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.settings-card__details {
|
||||
margin-left: 16px;
|
||||
border-left: 2px solid var(--cinnabar-soft);
|
||||
}
|
||||
|
||||
.settings-card__details--disabled {
|
||||
background: rgb(221 211 196 / 18%);
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
display: grid;
|
||||
min-height: 78px;
|
||||
padding: 13px 15px;
|
||||
grid-template-columns: 1fr 52px;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.setting-row + .setting-row,
|
||||
.settings-card__details .setting-row:first-child {
|
||||
border-top: 1px solid var(--outline-soft);
|
||||
}
|
||||
|
||||
.setting-row--detail {
|
||||
min-height: 72px;
|
||||
padding-left: 13px;
|
||||
}
|
||||
|
||||
.setting-row--disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
.setting-row__copy strong,
|
||||
.setting-row__copy small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.setting-row__copy strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.setting-row__copy small {
|
||||
margin-top: 3px;
|
||||
color: var(--ink-faint);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.switch-control {
|
||||
position: relative;
|
||||
width: 52px;
|
||||
height: 32px;
|
||||
margin: 0;
|
||||
appearance: none;
|
||||
background: #cfc5b7;
|
||||
border: 1px solid #b8ad9e;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: background-color 180ms ease, border-color 180ms ease;
|
||||
}
|
||||
|
||||
.switch-control::after {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--surface);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 6px rgb(47 36 25 / 18%);
|
||||
content: "";
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
.switch-control:checked {
|
||||
background: var(--cinnabar);
|
||||
border-color: var(--cinnabar);
|
||||
}
|
||||
|
||||
.switch-control:checked::after {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.switch-control:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.settings-ai-note {
|
||||
margin: 14px 2px 26px;
|
||||
color: var(--ink-faint);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.settings-danger {
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--outline-soft);
|
||||
}
|
||||
|
||||
.settings-danger h2 {
|
||||
margin-bottom: 4px;
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.settings-danger p {
|
||||
margin-bottom: 13px;
|
||||
color: var(--ink-faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settings-danger .button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.history-detail-header h1 {
|
||||
max-width: none;
|
||||
margin-bottom: 4px;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.history-detail-header h1 span {
|
||||
color: var(--outline);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.history-detail-header > p:last-child {
|
||||
color: var(--ink-faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.history-result-hero {
|
||||
margin-top: 20px;
|
||||
border: 1px solid var(--outline-soft);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.record-facts {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: rgb(255 253 247 / 70%);
|
||||
border: 1px solid var(--outline-soft);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.record-facts div {
|
||||
display: grid;
|
||||
padding: 13px 15px;
|
||||
grid-template-columns: 82px 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.record-facts div + div {
|
||||
border-top: 1px solid var(--outline-soft);
|
||||
}
|
||||
|
||||
.record-facts dt {
|
||||
color: var(--ink-faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.record-facts dd {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: 13px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.detail-danger-zone {
|
||||
margin-top: 32px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--outline-soft);
|
||||
}
|
||||
|
||||
.detail-danger-zone .button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.detail-danger-zone p {
|
||||
margin: 7px 0 0;
|
||||
color: var(--ink-faint);
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@keyframes page-in {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
@@ -417,6 +994,15 @@ h3 {
|
||||
border-color: rgb(155 52 45 / 28%);
|
||||
}
|
||||
|
||||
.button--danger {
|
||||
color: white;
|
||||
background: var(--error);
|
||||
}
|
||||
|
||||
.button--danger:hover {
|
||||
background: #812a25;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
color: #91877a;
|
||||
background: #ded6c9;
|
||||
@@ -453,6 +1039,11 @@ h3 {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.text-link--danger {
|
||||
color: var(--error);
|
||||
text-decoration-color: rgb(155 52 45 / 35%);
|
||||
}
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -995,6 +1586,99 @@ h3 {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.result-secondary-actions {
|
||||
display: grid;
|
||||
margin-top: 14px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.current-save-panel {
|
||||
display: grid;
|
||||
padding: 13px 14px 4px;
|
||||
gap: 6px;
|
||||
color: var(--success);
|
||||
background: #e7eee6;
|
||||
border: 1px solid #cbdacb;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.current-save-panel--off {
|
||||
padding-bottom: 13px;
|
||||
color: var(--ink-soft);
|
||||
background: rgb(255 253 247 / 72%);
|
||||
border-color: var(--outline-soft);
|
||||
}
|
||||
|
||||
.current-save-panel__status {
|
||||
display: grid;
|
||||
min-height: 46px;
|
||||
grid-template-columns: 36px 1fr;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.current-save-panel__icon {
|
||||
display: grid;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: rgb(255 255 255 / 62%);
|
||||
border-radius: 50%;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.current-save-panel__icon svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1.7;
|
||||
}
|
||||
|
||||
.current-save-panel__status strong,
|
||||
.current-save-panel__status small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.current-save-panel__status strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.current-save-panel__status small {
|
||||
margin-top: 2px;
|
||||
color: #526858;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.current-save-panel--off .current-save-panel__status small {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.current-save-panel__actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
border-top: 1px solid rgb(62 99 74 / 18%);
|
||||
}
|
||||
|
||||
.current-save-panel__actions .text-link {
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.current-save-panel__actions .text-link + .text-link {
|
||||
border-left: 1px solid rgb(62 99 74 / 18%);
|
||||
}
|
||||
|
||||
.current-save-panel--off > .button {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.current-save-panel--compact {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.interpretation-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
@@ -1138,6 +1822,10 @@ h3 {
|
||||
animation: sheet-in 240ms cubic-bezier(.2, .8, .2, 1) both;
|
||||
}
|
||||
|
||||
.modal-sheet--dialog {
|
||||
margin-bottom: max(18px, env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.modal-handle {
|
||||
width: 42px;
|
||||
height: 4px;
|
||||
@@ -1256,6 +1944,27 @@ h3 {
|
||||
accent-color: var(--cinnabar);
|
||||
}
|
||||
|
||||
.consent-check strong,
|
||||
.consent-check small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.consent-check small {
|
||||
margin-top: 2px;
|
||||
color: var(--ink-faint);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.notice--danger {
|
||||
color: #71322d;
|
||||
background: #fff3f0;
|
||||
border-color: #e4b9b3;
|
||||
}
|
||||
|
||||
.notice--danger svg {
|
||||
stroke: var(--error);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: grid;
|
||||
margin-top: 14px;
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
const trigramPatterns = Object.freeze({
|
||||
QIAN: ["YANG", "YANG", "YANG"],
|
||||
DUI: ["YANG", "YANG", "YIN"],
|
||||
LI: ["YANG", "YIN", "YANG"],
|
||||
ZHEN: ["YANG", "YIN", "YIN"],
|
||||
XUN: ["YIN", "YANG", "YANG"],
|
||||
KAN: ["YIN", "YANG", "YIN"],
|
||||
GEN: ["YIN", "YIN", "YANG"],
|
||||
KUN: ["YIN", "YIN", "YIN"],
|
||||
});
|
||||
|
||||
const kingWenPairs = Object.freeze([
|
||||
["QIAN", "QIAN"], ["KUN", "KUN"], ["KAN", "ZHEN"], ["GEN", "KAN"],
|
||||
["KAN", "QIAN"], ["QIAN", "KAN"], ["KUN", "KAN"], ["KAN", "KUN"],
|
||||
["XUN", "QIAN"], ["QIAN", "DUI"], ["KUN", "QIAN"], ["QIAN", "KUN"],
|
||||
["QIAN", "LI"], ["LI", "QIAN"], ["KUN", "GEN"], ["ZHEN", "KUN"],
|
||||
["DUI", "ZHEN"], ["GEN", "XUN"], ["KUN", "DUI"], ["XUN", "KUN"],
|
||||
["LI", "ZHEN"], ["GEN", "LI"], ["GEN", "KUN"], ["KUN", "ZHEN"],
|
||||
["QIAN", "ZHEN"], ["GEN", "QIAN"], ["GEN", "ZHEN"], ["DUI", "XUN"],
|
||||
["KAN", "KAN"], ["LI", "LI"], ["DUI", "GEN"], ["ZHEN", "XUN"],
|
||||
["QIAN", "GEN"], ["ZHEN", "QIAN"], ["LI", "KUN"], ["KUN", "LI"],
|
||||
["XUN", "LI"], ["LI", "DUI"], ["KAN", "GEN"], ["ZHEN", "KAN"],
|
||||
["GEN", "DUI"], ["XUN", "ZHEN"], ["DUI", "QIAN"], ["QIAN", "XUN"],
|
||||
["DUI", "KUN"], ["KUN", "XUN"], ["DUI", "KAN"], ["KAN", "XUN"],
|
||||
["DUI", "LI"], ["LI", "XUN"], ["ZHEN", "ZHEN"], ["GEN", "GEN"],
|
||||
["XUN", "GEN"], ["ZHEN", "DUI"], ["ZHEN", "LI"], ["LI", "GEN"],
|
||||
["XUN", "XUN"], ["DUI", "DUI"], ["XUN", "KAN"], ["KAN", "DUI"],
|
||||
["XUN", "DUI"], ["ZHEN", "GEN"], ["KAN", "LI"], ["LI", "KAN"],
|
||||
]);
|
||||
|
||||
const kingWenByPair = new Map(
|
||||
kingWenPairs.map(([upper, lower], index) => [`${upper}/${lower}`, index + 1]),
|
||||
);
|
||||
|
||||
const unsafeText = /(?:<\s*script\b|javascript\s*:|\bon\w+\s*=|[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f])/iu;
|
||||
|
||||
function isObject(value) {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function nonBlank(value) {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function validateText(value, path, errors) {
|
||||
if (!nonBlank(value)) {
|
||||
errors.push(`${path} must be non-blank text`);
|
||||
} else if (unsafeText.test(value)) {
|
||||
errors.push(`${path} contains script-like markup or an invisible control character`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateSixTexts(value, path, errors) {
|
||||
if (!Array.isArray(value) || value.length !== 6) {
|
||||
errors.push(`${path} must contain exactly six bottom-up entries`);
|
||||
return;
|
||||
}
|
||||
value.forEach((text, index) => validateText(text, `${path}[${index}]`, errors));
|
||||
}
|
||||
|
||||
function canonicalize(value) {
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`;
|
||||
if (isObject(value)) {
|
||||
return `{${Object.keys(value).sort().map((key) =>
|
||||
`${JSON.stringify(key)}:${canonicalize(value[key])}`).join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function contentDigest(contentPackage) {
|
||||
return createHash("sha256").update(canonicalize(contentPackage), "utf8").digest("hex");
|
||||
}
|
||||
|
||||
export function kingWenEntries() {
|
||||
return kingWenPairs.map(([upperTrigram, lowerTrigram], index) => ({
|
||||
kingWenNumber: index + 1,
|
||||
upperTrigram,
|
||||
lowerTrigram,
|
||||
patternBottomUp: [...trigramPatterns[lowerTrigram], ...trigramPatterns[upperTrigram]],
|
||||
}));
|
||||
}
|
||||
|
||||
export function validateContentPackage(contentPackage) {
|
||||
const errors = [];
|
||||
if (!isObject(contentPackage)) return ["content package must be an object"];
|
||||
if (contentPackage.schemaVersion !== 1) errors.push("schemaVersion must be 1");
|
||||
validateText(contentPackage.contentVersion, "contentVersion", errors);
|
||||
|
||||
const usage = contentPackage.specialUsageTexts;
|
||||
if (!isObject(usage) || typeof usage.qian !== "boolean" || typeof usage.kun !== "boolean") {
|
||||
errors.push("specialUsageTexts must declare boolean qian and kun flags");
|
||||
}
|
||||
|
||||
const sourceIds = new Set();
|
||||
if (!Array.isArray(contentPackage.sources) || contentPackage.sources.length === 0) {
|
||||
errors.push("sources must contain at least one licensed source");
|
||||
} else {
|
||||
contentPackage.sources.forEach((source, index) => {
|
||||
const prefix = `sources[${index}]`;
|
||||
if (!isObject(source)) {
|
||||
errors.push(`${prefix} must be an object`);
|
||||
return;
|
||||
}
|
||||
for (const field of ["id", "title", "edition", "license"]) {
|
||||
validateText(source[field], `${prefix}.${field}`, errors);
|
||||
}
|
||||
if (sourceIds.has(source.id)) errors.push(`${prefix}.id must be unique`);
|
||||
if (nonBlank(source.id)) sourceIds.add(source.id);
|
||||
try {
|
||||
const url = new URL(source.url);
|
||||
if (!["http:", "https:"].includes(url.protocol)) throw new Error("unsupported protocol");
|
||||
} catch {
|
||||
errors.push(`${prefix}.url must be an absolute HTTP(S) URL`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!Array.isArray(contentPackage.hexagrams) || contentPackage.hexagrams.length !== 64) {
|
||||
errors.push("hexagrams must contain exactly 64 entries");
|
||||
return errors;
|
||||
}
|
||||
|
||||
const seenIds = new Set();
|
||||
const seenPatterns = new Set();
|
||||
contentPackage.hexagrams.forEach((hexagram, index) => {
|
||||
const prefix = `hexagrams[${index}]`;
|
||||
if (!isObject(hexagram)) {
|
||||
errors.push(`${prefix} must be an object`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Number.isInteger(hexagram.kingWenNumber) || hexagram.kingWenNumber < 1 || hexagram.kingWenNumber > 64) {
|
||||
errors.push(`${prefix}.kingWenNumber must be an integer from 1 through 64`);
|
||||
} else if (seenIds.has(hexagram.kingWenNumber)) {
|
||||
errors.push(`${prefix}.kingWenNumber must be unique`);
|
||||
} else {
|
||||
seenIds.add(hexagram.kingWenNumber);
|
||||
}
|
||||
|
||||
for (const field of ["name", "symbol", "judgmentOriginal", "judgmentPlain"]) {
|
||||
validateText(hexagram[field], `${prefix}.${field}`, errors);
|
||||
}
|
||||
validateSixTexts(hexagram.lineTextsBottomUp, `${prefix}.lineTextsBottomUp`, errors);
|
||||
validateSixTexts(hexagram.linePlainBottomUp, `${prefix}.linePlainBottomUp`, errors);
|
||||
|
||||
const lowerPattern = trigramPatterns[hexagram.lowerTrigram];
|
||||
const upperPattern = trigramPatterns[hexagram.upperTrigram];
|
||||
if (!lowerPattern) errors.push(`${prefix}.lowerTrigram is unknown`);
|
||||
if (!upperPattern) errors.push(`${prefix}.upperTrigram is unknown`);
|
||||
|
||||
if (!Array.isArray(hexagram.patternBottomUp) || hexagram.patternBottomUp.length !== 6 ||
|
||||
hexagram.patternBottomUp.some((line) => line !== "YIN" && line !== "YANG")) {
|
||||
errors.push(`${prefix}.patternBottomUp must contain exactly six YIN/YANG values`);
|
||||
} else {
|
||||
const encoded = hexagram.patternBottomUp.join("/");
|
||||
if (seenPatterns.has(encoded)) errors.push(`${prefix}.patternBottomUp must be unique`);
|
||||
seenPatterns.add(encoded);
|
||||
if (lowerPattern && upperPattern) {
|
||||
const expected = [...lowerPattern, ...upperPattern];
|
||||
if (encoded !== expected.join("/")) {
|
||||
errors.push(`${prefix}.patternBottomUp does not match lower/upper trigrams in bottom-up order`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lowerPattern && upperPattern) {
|
||||
const expectedId = kingWenByPair.get(`${hexagram.upperTrigram}/${hexagram.lowerTrigram}`);
|
||||
if (hexagram.kingWenNumber !== expectedId) {
|
||||
errors.push(`${prefix}.kingWenNumber does not match its King Wen trigram pair`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(hexagram.sourceRefs) || hexagram.sourceRefs.length === 0) {
|
||||
errors.push(`${prefix}.sourceRefs must not be empty`);
|
||||
} else {
|
||||
for (const sourceRef of hexagram.sourceRefs) {
|
||||
if (!sourceIds.has(sourceRef)) errors.push(`${prefix}.sourceRefs contains unknown source '${sourceRef}'`);
|
||||
}
|
||||
if (new Set(hexagram.sourceRefs).size !== hexagram.sourceRefs.length) {
|
||||
errors.push(`${prefix}.sourceRefs must be unique`);
|
||||
}
|
||||
}
|
||||
|
||||
const hasSpecialText = nonBlank(hexagram.specialUsageText);
|
||||
if (hexagram.specialUsageText !== null && !hasSpecialText) {
|
||||
errors.push(`${prefix}.specialUsageText must be non-blank text or null`);
|
||||
}
|
||||
if (hasSpecialText) validateText(hexagram.specialUsageText, `${prefix}.specialUsageText`, errors);
|
||||
if (hexagram.kingWenNumber === 1 && isObject(usage) && hasSpecialText !== usage.qian) {
|
||||
errors.push(`${prefix}.specialUsageText must match specialUsageTexts.qian`);
|
||||
} else if (hexagram.kingWenNumber === 2 && isObject(usage) && hasSpecialText !== usage.kun) {
|
||||
errors.push(`${prefix}.specialUsageText must match specialUsageTexts.kun`);
|
||||
} else if (![1, 2].includes(hexagram.kingWenNumber) && hexagram.specialUsageText !== null) {
|
||||
errors.push(`${prefix}.specialUsageText is only valid for Qian or Kun`);
|
||||
}
|
||||
});
|
||||
|
||||
if (seenIds.size !== 64) errors.push("King Wen numbers 1 through 64 must each occur exactly once");
|
||||
if (seenPatterns.size !== 64) errors.push("all 64 polarity patterns must each occur exactly once");
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { readdir, readFile, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export const repositoryRoot = path.resolve(import.meta.dirname, "..");
|
||||
|
||||
const excludedDirectories = new Set([
|
||||
".git",
|
||||
".gradle",
|
||||
".idea",
|
||||
"build",
|
||||
"node_modules",
|
||||
]);
|
||||
|
||||
export async function walkFiles(directory = repositoryRoot) {
|
||||
const result = [];
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && excludedDirectories.has(entry.name)) continue;
|
||||
const absolutePath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
result.push(...(await walkFiles(absolutePath)));
|
||||
} else if (entry.isFile()) {
|
||||
result.push(absolutePath);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function readTextFileIfSmall(file, maximumBytes = 2_000_000) {
|
||||
const metadata = await stat(file);
|
||||
if (metadata.size > maximumBytes) return null;
|
||||
const buffer = await readFile(file);
|
||||
if (buffer.includes(0)) return null;
|
||||
return buffer.toString("utf8");
|
||||
}
|
||||
|
||||
export function relative(file) {
|
||||
return path.relative(repositoryRoot, file).replaceAll(path.sep, "/");
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
readTextFileIfSmall,
|
||||
relative,
|
||||
walkFiles,
|
||||
} from "./repository-files.mjs";
|
||||
|
||||
const patterns = [
|
||||
["private key block", /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/u],
|
||||
["GitHub token", /\bgh[pousr]_[A-Za-z0-9]{30,}\b/u],
|
||||
["OpenAI-style key", /\bsk-[A-Za-z0-9_-]{20,}\b/u],
|
||||
["Google API key", /\bAIza[0-9A-Za-z_-]{35}\b/u],
|
||||
["AWS access key", /\bAKIA[0-9A-Z]{16}\b/u],
|
||||
];
|
||||
|
||||
const excludedExtensions = new Set([
|
||||
".gif",
|
||||
".ico",
|
||||
".jpeg",
|
||||
".jpg",
|
||||
".pdf",
|
||||
".png",
|
||||
".webp",
|
||||
".zip",
|
||||
]);
|
||||
|
||||
const findings = [];
|
||||
for (const file of await walkFiles()) {
|
||||
if (excludedExtensions.has(path.extname(file).toLowerCase())) continue;
|
||||
const content = await readTextFileIfSmall(file);
|
||||
if (content === null) continue;
|
||||
|
||||
for (const [label, pattern] of patterns) {
|
||||
const match = pattern.exec(content);
|
||||
if (!match) continue;
|
||||
const line = content.slice(0, match.index).split(/\r?\n/u).length;
|
||||
findings.push(`${relative(file)}:${line} (${label})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (findings.length > 0) {
|
||||
console.error("Potential secrets found; values are intentionally redacted:");
|
||||
for (const finding of findings) console.error(`- ${finding}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log("High-confidence secret scan passed.");
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
contentDigest,
|
||||
kingWenEntries,
|
||||
validateContentPackage,
|
||||
} from "./content-contract.mjs";
|
||||
import { repositoryRoot } from "./repository-files.mjs";
|
||||
|
||||
const schemaPath = path.join(repositoryRoot, "content", "schema", "hexagram-content.schema.json");
|
||||
const schema = JSON.parse(await readFile(schemaPath, "utf8"));
|
||||
if (schema.$schema !== "https://json-schema.org/draft/2020-12/schema") {
|
||||
throw new Error("Content JSON Schema must use draft 2020-12");
|
||||
}
|
||||
|
||||
const kotlinCatalogPath = path.join(
|
||||
repositoryRoot,
|
||||
"app",
|
||||
"src",
|
||||
"main",
|
||||
"java",
|
||||
"brainwave",
|
||||
"domain",
|
||||
"casting",
|
||||
"HexagramCatalog.kt",
|
||||
);
|
||||
const kotlinCatalog = await readFile(kotlinCatalogPath, "utf8");
|
||||
const kotlinPairPattern = /TrigramPair\(Trigram\.(\w+), Trigram\.(\w+)\) to (\d+)/gu;
|
||||
const kotlinPairs = new Map(
|
||||
[...kotlinCatalog.matchAll(kotlinPairPattern)].map((match) => [
|
||||
`${match[1]}/${match[2]}`,
|
||||
Number(match[3]),
|
||||
]),
|
||||
);
|
||||
const contractEntries = kingWenEntries();
|
||||
if (kotlinPairs.size !== 64 || contractEntries.some((entry) =>
|
||||
kotlinPairs.get(`${entry.upperTrigram}/${entry.lowerTrigram}`) !== entry.kingWenNumber)) {
|
||||
throw new Error("Kotlin King Wen catalog and content-contract lookup must match exactly");
|
||||
}
|
||||
|
||||
function validFixture() {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
contentVersion: "fixture-only-v1",
|
||||
specialUsageTexts: { qian: false, kun: false },
|
||||
sources: [{
|
||||
id: "fixture-source",
|
||||
title: "Automated test fixture",
|
||||
edition: "not publishable",
|
||||
license: "test data only",
|
||||
url: "https://example.invalid/fixture",
|
||||
}],
|
||||
hexagrams: contractEntries.map((entry) => ({
|
||||
...entry,
|
||||
name: `fixture-${entry.kingWenNumber}`,
|
||||
symbol: `fixture-symbol-${entry.kingWenNumber}`,
|
||||
judgmentOriginal: "fixture original text",
|
||||
judgmentPlain: "fixture plain text",
|
||||
lineTextsBottomUp: Array.from({ length: 6 }, (_, index) => `fixture original line ${index + 1}`),
|
||||
linePlainBottomUp: Array.from({ length: 6 }, (_, index) => `fixture plain line ${index + 1}`),
|
||||
specialUsageText: null,
|
||||
sourceRefs: ["fixture-source"],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function clone(value) {
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
const fixture = validFixture();
|
||||
const validErrors = validateContentPackage(fixture);
|
||||
if (validErrors.length > 0) {
|
||||
throw new Error(`Valid content fixture was rejected:\n${validErrors.join("\n")}`);
|
||||
}
|
||||
|
||||
const digest = contentDigest(fixture);
|
||||
if (!/^[a-f0-9]{64}$/u.test(digest) || digest !== contentDigest(clone(fixture))) {
|
||||
throw new Error("Content digest must be a stable SHA-256 value");
|
||||
}
|
||||
|
||||
const negativeCases = [
|
||||
["unsupported schema", (value) => { value.schemaVersion = 2; }, "schemaVersion"],
|
||||
["duplicate id", (value) => { value.hexagrams[1].kingWenNumber = 1; }, "unique"],
|
||||
["wrong bottom-up pattern", (value) => { value.hexagrams[0].patternBottomUp[0] = "YIN"; }, "bottom-up"],
|
||||
["five line texts", (value) => { value.hexagrams[0].lineTextsBottomUp.pop(); }, "exactly six"],
|
||||
["unknown source", (value) => { value.hexagrams[0].sourceRefs = ["missing"]; }, "unknown source"],
|
||||
["blank license", (value) => { value.sources[0].license = " "; }, "non-blank"],
|
||||
["script content", (value) => { value.hexagrams[0].judgmentPlain = "<script>alert(1)</script>"; }, "script-like"],
|
||||
["special declaration mismatch", (value) => { value.specialUsageTexts.qian = true; }, "specialUsageTexts.qian"],
|
||||
];
|
||||
|
||||
for (const [name, mutate, expected] of negativeCases) {
|
||||
const candidate = clone(fixture);
|
||||
mutate(candidate);
|
||||
const errors = validateContentPackage(candidate);
|
||||
if (!errors.some((error) => error.includes(expected))) {
|
||||
throw new Error(`${name} fixture did not fail with '${expected}':\n${errors.join("\n")}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Content contract verification passed (64 entries cross-checked with Kotlin, ${negativeCases.length} negative fixtures, digest ${digest.slice(0, 12)}…).`,
|
||||
);
|
||||
@@ -0,0 +1,75 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { repositoryRoot } from "./repository-files.mjs";
|
||||
|
||||
const manifestPath = path.join(repositoryRoot, "config", "dependency-licenses.json");
|
||||
const catalogPath = path.join(repositoryRoot, "gradle", "libs.versions.toml");
|
||||
const wrapperPath = path.join(repositoryRoot, "gradle", "wrapper", "gradle-wrapper.properties");
|
||||
const documentationPath = path.join(repositoryRoot, "docs", "dependency-licenses.md");
|
||||
|
||||
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
||||
const catalog = await readFile(catalogPath, "utf8");
|
||||
const wrapper = await readFile(wrapperPath, "utf8");
|
||||
const documentation = await readFile(documentationPath, "utf8");
|
||||
const failures = [];
|
||||
|
||||
if (manifest.schemaVersion !== 1) failures.push("manifest schemaVersion must be 1");
|
||||
if (!Array.isArray(manifest.components) || manifest.components.length === 0) {
|
||||
failures.push("manifest components must not be empty");
|
||||
}
|
||||
|
||||
const allowedLicenses = new Set(["Apache-2.0", "BSD-3-Clause", "EPL-1.0"]);
|
||||
const ids = new Set();
|
||||
const coordinates = new Set();
|
||||
for (const [index, component] of (manifest.components ?? []).entries()) {
|
||||
const prefix = `components[${index}]`;
|
||||
for (const field of ["id", "version", "scope", "license", "source"]) {
|
||||
if (typeof component[field] !== "string" || component[field].trim() === "") {
|
||||
failures.push(`${prefix}.${field} must be non-blank text`);
|
||||
}
|
||||
}
|
||||
if (ids.has(component.id)) failures.push(`${prefix}.id must be unique`);
|
||||
ids.add(component.id);
|
||||
if (component.coordinates !== null) {
|
||||
if (typeof component.coordinates !== "string" || component.coordinates.split(":").length !== 3) {
|
||||
failures.push(`${prefix}.coordinates must be group:name:version or null`);
|
||||
} else if (coordinates.has(component.coordinates)) {
|
||||
failures.push(`${prefix}.coordinates must be unique`);
|
||||
}
|
||||
coordinates.add(component.coordinates);
|
||||
}
|
||||
if (!allowedLicenses.has(component.license)) failures.push(`${prefix}.license is not reviewed`);
|
||||
try {
|
||||
const url = new URL(component.source);
|
||||
if (url.protocol !== "https:") throw new Error("not HTTPS");
|
||||
} catch {
|
||||
failures.push(`${prefix}.source must be an absolute HTTPS URL`);
|
||||
}
|
||||
if (!documentation.includes(component.id) ||
|
||||
!documentation.includes(component.version) ||
|
||||
!documentation.includes(component.license)) {
|
||||
failures.push(`${prefix} is not represented in docs/dependency-licenses.md`);
|
||||
}
|
||||
}
|
||||
|
||||
const version = (name) => new RegExp(`^${name}\\s*=\\s*"([^"]+)"`, "mu").exec(catalog)?.[1];
|
||||
const expectedVersions = new Map([
|
||||
["gradle-wrapper", /gradle-([\d.]+)-bin\.zip/u.exec(wrapper)?.[1]],
|
||||
["kotlin-gradle-plugin", version("kotlin")],
|
||||
["kotlin-stdlib", version("kotlin")],
|
||||
["junit4", version("junit")],
|
||||
]);
|
||||
for (const [id, expectedVersion] of expectedVersions) {
|
||||
const component = manifest.components.find((candidate) => candidate.id === id);
|
||||
if (!component || component.version !== expectedVersion) {
|
||||
failures.push(`${id} license entry must match pinned version ${expectedVersion ?? "<missing>"}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error("Dependency-license verification failed:");
|
||||
for (const failure of failures) console.error(`- ${failure}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(`Dependency-license manifest passed (${manifest.components.length} reviewed components).`);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { access } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
readTextFileIfSmall,
|
||||
relative,
|
||||
repositoryRoot,
|
||||
walkFiles,
|
||||
} from "./repository-files.mjs";
|
||||
|
||||
const requiredDocuments = [
|
||||
"AGENTS.md",
|
||||
"docs/README.md",
|
||||
"docs/agent-playbook.md",
|
||||
"docs/implementation-plan.md",
|
||||
"docs/quality-gates.md",
|
||||
"docs/decisions.md",
|
||||
];
|
||||
|
||||
const failures = [];
|
||||
|
||||
for (const required of requiredDocuments) {
|
||||
try {
|
||||
await access(path.join(repositoryRoot, required));
|
||||
} catch {
|
||||
failures.push(`missing required document: ${required}`);
|
||||
}
|
||||
}
|
||||
|
||||
const markdownFiles = (await walkFiles()).filter((file) => file.endsWith(".md"));
|
||||
const markdownLink = /!?\[[^\]]*\]\(([^)]+)\)/g;
|
||||
|
||||
for (const markdownFile of markdownFiles) {
|
||||
const content = await readTextFileIfSmall(markdownFile);
|
||||
if (content === null) continue;
|
||||
|
||||
for (const match of content.matchAll(markdownLink)) {
|
||||
let target = match[1].trim();
|
||||
if (target.startsWith("<") && target.endsWith(">")) {
|
||||
target = target.slice(1, -1);
|
||||
}
|
||||
target = target.split(/\s+["']/u, 1)[0];
|
||||
if (
|
||||
target === "" ||
|
||||
target.startsWith("#") ||
|
||||
/^[a-z][a-z\d+.-]*:/iu.test(target)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pathPart = target.split("#", 1)[0].split("?", 1)[0];
|
||||
const decodedPath = decodeURIComponent(pathPart);
|
||||
const resolved = path.resolve(path.dirname(markdownFile), decodedPath);
|
||||
try {
|
||||
await access(resolved);
|
||||
} catch {
|
||||
failures.push(`${relative(markdownFile)} -> ${target}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error("Documentation verification failed:");
|
||||
for (const failure of failures) console.error(`- ${failure}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(`Documentation verification passed (${markdownFiles.length} Markdown files).`);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
readTextFileIfSmall,
|
||||
relative,
|
||||
repositoryRoot,
|
||||
walkFiles,
|
||||
} from "./repository-files.mjs";
|
||||
|
||||
const domainRoot = path.join(repositoryRoot, "app", "src", "main", "java", "brainwave", "domain");
|
||||
const forbiddenImports = [
|
||||
/^android\./u,
|
||||
/^androidx\./u,
|
||||
/^com\.google\.dagger\./u,
|
||||
/^dagger\./u,
|
||||
/^okhttp3\./u,
|
||||
/^retrofit2\./u,
|
||||
/^io\.ktor\./u,
|
||||
/^androidx\.room\./u,
|
||||
];
|
||||
|
||||
const failures = [];
|
||||
let kotlinFiles = [];
|
||||
try {
|
||||
kotlinFiles = (await walkFiles(domainRoot)).filter((file) => file.endsWith(".kt"));
|
||||
} catch {
|
||||
failures.push(`domain source directory is missing: ${relative(domainRoot)}`);
|
||||
}
|
||||
|
||||
for (const file of kotlinFiles) {
|
||||
const content = await readTextFileIfSmall(file);
|
||||
if (content === null) continue;
|
||||
for (const [index, line] of content.split(/\r?\n/u).entries()) {
|
||||
const match = /^\s*import\s+([^\s]+)/u.exec(line);
|
||||
if (!match) continue;
|
||||
if (forbiddenImports.some((pattern) => pattern.test(match[1]))) {
|
||||
failures.push(`${relative(file)}:${index + 1} forbidden import ${match[1]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error("Domain boundary verification failed:");
|
||||
for (const failure of failures) console.error(`- ${failure}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(`Domain boundary verification passed (${kotlinFiles.length} Kotlin files).`);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
readTextFileIfSmall,
|
||||
relative,
|
||||
walkFiles,
|
||||
} from "./repository-files.mjs";
|
||||
|
||||
const checkedExtensions = new Set([
|
||||
".css",
|
||||
".html",
|
||||
".js",
|
||||
".json",
|
||||
".kt",
|
||||
".kts",
|
||||
".md",
|
||||
".mjs",
|
||||
".properties",
|
||||
".toml",
|
||||
".xml",
|
||||
".yaml",
|
||||
".yml",
|
||||
]);
|
||||
|
||||
const failures = [];
|
||||
let checked = 0;
|
||||
for (const file of await walkFiles()) {
|
||||
if (!checkedExtensions.has(path.extname(file).toLowerCase())) continue;
|
||||
const content = await readTextFileIfSmall(file);
|
||||
if (content === null) continue;
|
||||
checked += 1;
|
||||
|
||||
if (content.length > 0 && !content.endsWith("\n")) {
|
||||
failures.push(`${relative(file)} must end with a newline`);
|
||||
}
|
||||
content.split(/\r?\n/u).forEach((line, index) => {
|
||||
if (/[ \t]+$/u.test(line)) failures.push(`${relative(file)}:${index + 1} has trailing whitespace`);
|
||||
});
|
||||
|
||||
if (file.endsWith(".json")) {
|
||||
try {
|
||||
JSON.parse(content);
|
||||
} catch (error) {
|
||||
failures.push(`${relative(file)} is invalid JSON: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error("Formatting verification failed:");
|
||||
for (const failure of failures) console.error(`- ${failure}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(`Formatting verification passed (${checked} text files).`);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { repositoryRoot } from "./repository-files.mjs";
|
||||
|
||||
const scripts = ["prototype/app.js", "prototype/capture.mjs"];
|
||||
const failures = [];
|
||||
|
||||
for (const script of scripts) {
|
||||
const result = spawnSync(process.execPath, ["--check", path.join(repositoryRoot, script)], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
failures.push(`${script}: ${(result.stderr || result.stdout).trim()}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error("Prototype syntax verification failed:");
|
||||
for (const failure of failures) console.error(`- ${failure}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(`Prototype syntax verification passed (${scripts.length} files).`);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
gradlePluginPortal()
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
// Repository code name only. This is not the formal product name (TBD-001).
|
||||
rootProject.name = "brainwave"
|
||||
include(":app")
|
||||