Baseline: wave 1A server complete, wave 1B Android scaffold + design system green

assembleDebug, testDebugUnitTest, and recordPaparazziDebug all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bThmkmyUUdqQpy3MXFFe5
This commit is contained in:
2026-09-06 01:58:37 +00:00
commit 6c17e42037
79 changed files with 3773 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# build outputs
app/build/
app/app/build/
app/.gradle/
app/.kotlin/
app/local.properties
# server runtime
server/pb_data/
server/pocketbase
server/.dev-credentials
# worker logs
logs/*.json
logs/*.err
logs/*.state
+12
View File
@@ -0,0 +1,12 @@
*.iml
.gradle/
/local.properties
.idea/
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
**/build/
.kotlin/
+93
View File
@@ -0,0 +1,93 @@
Copyright 2017 The Literata Project Authors (https://github.com/googlefonts/literata)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+117
View File
@@ -0,0 +1,117 @@
plugins {
alias(libs.plugins.android.application)
// NOTE: no org.jetbrains.kotlin.android plugin — AGP 9's Kotlin support is
// built in. Only sub-plugins that add compiler extensions are applied here.
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
alias(libs.plugins.paparazzi)
}
android {
namespace = "org.modg.bookshelf"
compileSdk = 37
buildToolsVersion = "37.0.0"
defaultConfig {
applicationId = "org.modg.bookshelf"
minSdk = 26
targetSdk = 37
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
buildFeatures {
compose = true
}
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21)
}
}
// Paparazzi's Gradle plugin writes an HTML test report that trips on AGP 9's
// new DSL (cashapp/paparazzi#2111). Disable it; the PNG snapshots/report task
// output is what we actually care about here.
tasks.withType<Test>().configureEach {
reports.html.required.set(false)
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.navigation.compose)
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.material.icons.extended)
debugImplementation(libs.compose.ui.tooling)
debugImplementation(libs.compose.ui.test.manifest)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
implementation(libs.retrofit.core)
implementation(libs.retrofit.kotlinx.serialization.converter)
implementation(libs.okhttp.logging.interceptor)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
implementation(libs.androidx.camera.core)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
implementation(libs.mlkit.barcode.scanning)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.datastore.preferences)
implementation(libs.accompanist.permissions)
testImplementation(libs.junit)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core)
testImplementation(libs.androidx.test.ext.junit)
testImplementation(libs.androidx.room.testing)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(platform(libs.compose.bom))
testImplementation(libs.compose.ui.test.manifest)
}
+3
View File
@@ -0,0 +1,3 @@
# Add project specific ProGuard rules here.
# Release minification is currently disabled (isMinifyEnabled = false); this
# file is a placeholder for when wave-3+ enables it.
+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Camera for barcode scanning (ui.scan) -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<uses-feature
android:name="android.hardware.camera.autofocus"
android:required="false" />
<!-- PocketBase sync, metadata lookup (Open Library / Google Books), cover uploads -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".BookshelfApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.Bookshelf"
tools:targetApi="31"
xmlns:tools="http://schemas.android.com/tools">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Bookshelf">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,12 @@
package org.modg.bookshelf
import android.app.Application
/**
* Application entry point. Per SPEC, DI is a hand-rolled [AppContainer] held
* here (no Hilt/kapt) — data.repo/data.local/data.remote wiring lands with
* the waves that introduce those packages. This scaffold wave intentionally
* leaves the container empty rather than stubbing out APIs that don't exist
* yet.
*/
class BookshelfApplication : Application()
@@ -0,0 +1,128 @@
package org.modg.bookshelf
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.modg.bookshelf.ui.components.BookCover
import org.modg.bookshelf.ui.components.BookshelfScaffold
import org.modg.bookshelf.ui.components.EmptyState
import org.modg.bookshelf.ui.components.GoldDivider
import org.modg.bookshelf.ui.components.PaperSurface
import org.modg.bookshelf.ui.components.PrimaryButton
import org.modg.bookshelf.ui.components.SecondaryButton
import org.modg.bookshelf.ui.components.SyncStatus
import org.modg.bookshelf.ui.components.SyncStatusBar
import org.modg.bookshelf.ui.theme.BookshelfTheme
/**
* Wave 2's placeholder home screen. There is no navigation graph yet (that's
* ui.nav.BookshelfNavHost, a later wave) — this Activity exists purely as a
* living style reference for the component set above, so wave 3 can see
* exactly how BookshelfScaffold/BookCover/etc. are meant to be used.
*/
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
BookshelfTheme {
StyleReferenceScreen()
}
}
}
}
private data class SampleBook(val title: String, val coverUrl: String?)
private val sampleBooks = listOf(
SampleBook("The Left Hand of Darkness", "https://covers.openlibrary.org/b/isbn/9780441478125-L.jpg"),
SampleBook("A broken cover URL", "https://example.invalid/nope.jpg"),
SampleBook("No cover on file", null),
SampleBook("Gödel, Escher, Bach", null),
)
@Composable
private fun StyleReferenceScreen() {
var showEmptyState by remember { mutableStateOf(false) }
BookshelfScaffold(
title = "Bookshelf",
actions = {
IconButton(onClick = { showEmptyState = !showEmptyState }) {
Icon(Icons.Outlined.Settings, contentDescription = "Toggle empty state")
}
},
syncStatusBar = {
SyncStatusBar(status = SyncStatus.Synced, label = "Synced • just now")
},
) { innerPadding ->
PaperSurface(modifier = Modifier.fillMaxWidth()) {
if (showEmptyState) {
EmptyState(
modifier = Modifier.padding(innerPadding),
title = "Your shelves are empty",
message = "Scan a barcode to add your first book.",
action = { PrimaryButton(text = "Scan a book", onClick = {}) },
)
} else {
Column(modifier = Modifier.padding(innerPadding)) {
SectionLabel("Covers")
LazyVerticalGrid(
columns = GridCells.Fixed(3),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
items(sampleBooks) { book ->
BookCover(coverUrl = book.coverUrl, contentDescription = book.title)
}
}
GoldDivider(modifier = Modifier.padding(vertical = 8.dp))
SectionLabel("Buttons")
Row(
modifier = Modifier.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
PrimaryButton(text = "Save", onClick = {})
SecondaryButton(text = "Skip", onClick = {})
}
}
}
}
}
}
@Composable
private fun SectionLabel(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp),
)
}
@@ -0,0 +1,89 @@
package org.modg.bookshelf.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AutoStories
import androidx.compose.material.icons.outlined.BrokenImage
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
/** The canonical book-cover aspect ratio (standard trade paperback proportions). */
const val BookCoverAspectRatio = 2f / 3f
/**
* A book cover image, always drawn at [BookCoverAspectRatio]. Covers are the
* hero of this app's design — real art fills the whole shape edge to edge.
* When there's no [coverUrl], or the load fails, we fall back to the same
* restrained "letterpress" placeholder: a paper-toned panel with a debossed
* spine motif rather than a broken-image icon or empty grey box.
*/
@Composable
fun BookCover(
coverUrl: String?,
contentDescription: String?,
modifier: Modifier = Modifier,
shape: androidx.compose.ui.graphics.Shape = MaterialTheme.shapes.small,
) {
Box(
modifier = modifier
.aspectRatio(BookCoverAspectRatio)
.clip(shape),
) {
if (coverUrl.isNullOrBlank()) {
CoverPlaceholder(errored = false)
} else {
SubcomposeAsyncImage(
model = coverUrl,
contentDescription = contentDescription,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
) {
when (painter.state) {
is AsyncImagePainter.State.Error -> CoverPlaceholder(errored = true)
is AsyncImagePainter.State.Loading,
is AsyncImagePainter.State.Empty,
-> CoverPlaceholder(errored = false, loading = true)
is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent()
}
}
}
}
}
@Composable
private fun CoverPlaceholder(errored: Boolean, loading: Boolean = false) {
val paperAlt = MaterialTheme.colorScheme.surfaceVariant
val ink = MaterialTheme.colorScheme.onSurfaceVariant
Box(
modifier = Modifier
.fillMaxSize()
.background(paperAlt)
.border(width = 1.dp, color = ink.copy(alpha = 0.15f))
.padding(2.dp)
.border(width = 1.dp, color = ink.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center,
) {
if (!loading) {
Icon(
imageVector = if (errored) Icons.Outlined.BrokenImage else Icons.Outlined.AutoStories,
contentDescription = null,
tint = ink.copy(alpha = if (errored) 0.35f else 0.28f),
modifier = Modifier.fillMaxSize(0.32f),
)
}
}
}
@@ -0,0 +1,62 @@
package org.modg.bookshelf.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* The shell every Bookshelf screen is built on: a top bar with the serif
* screen title and a gold hairline rule underneath, plus optional nav/action
* slots, FAB, and a bottom [SyncStatusBar] slot. Screens should reach for
* this instead of a bare [Scaffold] so the chrome stays consistent.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BookshelfScaffold(
title: String,
modifier: Modifier = Modifier,
navigationIcon: @Composable () -> Unit = {},
actions: @Composable () -> Unit = {},
floatingActionButton: @Composable () -> Unit = {},
syncStatusBar: @Composable () -> Unit = {},
content: @Composable (PaddingValues) -> Unit,
) {
Scaffold(
modifier = modifier,
topBar = {
Column {
CenterAlignedTopAppBar(
title = {
Text(text = title, style = MaterialTheme.typography.titleLarge)
},
navigationIcon = navigationIcon,
actions = { actions() },
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
),
)
GoldDivider()
}
},
floatingActionButton = floatingActionButton,
containerColor = MaterialTheme.colorScheme.surface,
content = { innerPadding ->
Column(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.weight(1f)) {
content(innerPadding)
}
syncStatusBar()
}
},
)
}
@@ -0,0 +1,62 @@
package org.modg.bookshelf.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
private val ButtonShape = RoundedCornerShape(6.dp)
private val ButtonPadding = PaddingValues(horizontal = 24.dp, vertical = 12.dp)
/** The mahogany-filled call to action button — Save, Add to shelf, Sign in, etc. */
@Composable
fun PrimaryButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
Button(
onClick = onClick,
modifier = modifier,
enabled = enabled,
shape = ButtonShape,
contentPadding = ButtonPadding,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
),
) {
Text(text = text, style = MaterialTheme.typography.labelLarge)
}
}
/** The quieter outlined companion — Cancel, Skip, secondary actions. */
@Composable
fun SecondaryButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
OutlinedButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
shape = ButtonShape,
contentPadding = ButtonPadding,
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.primary,
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
) {
Text(text = text, style = MaterialTheme.typography.labelLarge)
}
}
@@ -0,0 +1,64 @@
package org.modg.bookshelf.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AutoStories
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
/**
* A quiet, on-brand empty state — an icon, a serif headline, a supporting
* line, and an optional call to action (e.g. library's "invite the first
* scan"). Never just blank space.
*/
@Composable
fun EmptyState(
title: String,
modifier: Modifier = Modifier,
message: String? = null,
action: @Composable (() -> Unit)? = null,
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = Icons.Outlined.AutoStories,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(bottom = 16.dp),
)
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
)
if (message != null) {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 8.dp),
)
}
if (action != null) {
Column(modifier = Modifier.padding(top = 24.dp)) {
action()
}
}
}
}
@@ -0,0 +1,45 @@
package org.modg.bookshelf.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.unit.dp
/**
* The thin gold hairline rule used under topbars and between sections — one
* of the app's few recurring "motifs" per the SPEC. Fades to transparent at
* both ends rather than terminating abruptly, so it reads as a rule on the
* page instead of a UI divider.
*/
@Composable
fun GoldDivider(modifier: Modifier = Modifier) {
val gold = MaterialTheme.colorScheme.secondary
Box(
modifier = modifier
.fillMaxWidth()
.height(1.dp)
.drawBehind {
val brush = Brush.horizontalGradient(
colors = listOf(
gold.copy(alpha = 0f),
gold.copy(alpha = 0.8f),
gold.copy(alpha = 0f),
),
startX = 0f,
endX = size.width,
)
drawLine(
brush = brush,
start = Offset(0f, size.height / 2f),
end = Offset(size.width, size.height / 2f),
strokeWidth = size.height,
)
},
)
}
@@ -0,0 +1,61 @@
package org.modg.bookshelf.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.random.Random
/**
* The base surface for large areas of the app (screen backgrounds, cards).
* Paints [MaterialTheme]'s surface color plus a very faint, deterministic
* paper-grain speckle so big flat areas read as paper rather than a plain
* digital fill. The grain is intentionally subtle — this is restrained, not
* skeuomorphic parchment.
*/
@Composable
fun PaperSurface(
modifier: Modifier = Modifier,
grain: Boolean = true,
contentPadding: Dp = 0.dp,
content: @Composable () -> Unit,
) {
Box(
modifier = modifier
.background(MaterialTheme.colorScheme.surface)
.then(if (grain) Modifier.paperGrain() else Modifier)
.padding(contentPadding),
) {
content()
}
}
/** Deterministic speckle grain, cheap enough to redraw every frame. */
private fun Modifier.paperGrain(): Modifier = drawWithCache {
val random = Random(seed = 42)
val speckleCount = ((size.width * size.height) / 9000f).toInt().coerceIn(24, 900)
val speckles = List(speckleCount) {
Offset(random.nextFloat() * size.width, random.nextFloat() * size.height) to
(0.35f + random.nextFloat() * 0.5f)
}
onDrawWithContent {
drawContent()
drawIntoCanvas {
for ((offset, radiusScale) in speckles) {
drawCircle(
color = Color.Black.copy(alpha = 0.018f * radiusScale),
radius = 1.1f * radiusScale,
center = offset,
)
}
}
}
}
@@ -0,0 +1,100 @@
package org.modg.bookshelf.ui.components
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
/**
* Presentation-only sync state for [SyncStatusBar]. The real sync engine
* (data.repo.SyncEngine) maps its richer state into this — never the other
* way around. Per SPEC: sync failure is a quiet status line, never a dialog.
*/
enum class SyncStatus {
Synced,
Syncing,
Offline,
Error,
}
/**
* A slim, quiet status line — never a blocking banner or dialog. Sits at the
* bottom of [BookshelfScaffold] screens.
*/
@Composable
fun SyncStatusBar(
status: SyncStatus,
label: String,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainerLow)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
StatusDot(status)
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun StatusDot(status: SyncStatus) {
val color = when (status) {
SyncStatus.Synced -> MaterialTheme.colorScheme.secondary
SyncStatus.Syncing -> MaterialTheme.colorScheme.secondary
SyncStatus.Offline -> MaterialTheme.colorScheme.onSurfaceVariant
SyncStatus.Error -> MaterialTheme.colorScheme.error
}
if (status == SyncStatus.Syncing) {
val transition = rememberInfiniteTransition(label = "sync-pulse")
val alpha by transition.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(700, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
),
label = "sync-pulse-alpha",
)
Dot(color = color, modifier = Modifier.graphicsLayer { this.alpha = alpha })
} else {
Dot(color = color)
}
}
@Composable
private fun Dot(color: Color, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(8.dp)
.clip(CircleShape)
.background(color),
)
}
@@ -0,0 +1,57 @@
package org.modg.bookshelf.ui.theme
import androidx.compose.ui.graphics.Color
// Palette straight from docs/SPEC.md — "feels like books": warm paper, dark
// mahogany, gold + silver metallics. Do not add colors outside this family;
// if a new role is needed, derive it from these rather than inventing a hue.
// ---- Light ----
val PaperLight = Color(0xFFF5EDE0)
val PaperAltLight = Color(0xFFEDE3D2)
val InkLight = Color(0xFF2B211A)
val InkSoftLight = Color(0xFF5A4A3D)
val MahoganyLight = Color(0xFF5C2E23)
val MahoganyDeepLight = Color(0xFF3E1E17)
val GoldLight = Color(0xFFC0932F)
val GoldSoftLight = Color(0xFFD9B45B)
val SilverLight = Color(0xFF9CA3AF)
val SilverSoftLight = Color(0xFFC7CCD1)
val ErrorLight = Color(0xFF8C3B2E)
val ErrorContainerLight = Color(0xFFF2D9D1)
val OnErrorContainerLight = Color(0xFF4A190F)
// Extra surface tones derived from PaperLight/PaperAltLight for the M3
// surface-container ladder (introduced after the base M3 ColorScheme; these
// keep every elevation looking like a shade of paper, never grey).
val SurfaceDimLight = Color(0xFFE4D8C5)
val SurfaceBrightLight = Color(0xFFFBF6EC)
val SurfaceContainerLowestLight = Color(0xFFFFFFFF)
val SurfaceContainerLowLight = Color(0xFFF1E7D7)
val SurfaceContainerLight = Color(0xFFEDE3D2)
val SurfaceContainerHighLight = Color(0xFFE7DAC5)
val SurfaceContainerHighestLight = Color(0xFFE0D0B7)
// ---- Dark ----
val GroundDark = Color(0xFF1C1411)
val SurfaceDark = Color(0xFF241A15)
val PaperTextDark = Color(0xFFE8DCC8)
val MahoganyDark = Color(0xFF7A3E2F)
val MahoganyDeepDark = Color(0xFF3E1E17)
val GoldDark = Color(0xFFD9B45B)
val SilverDark = Color(0xFFC7CCD1)
val OnSurfaceVariantDark = Color(0xFFC7B9A3)
val SurfaceVariantDark = Color(0xFF2E2119)
val OutlineDark = Color(0xFF6B6F74)
val OutlineVariantDark = Color(0xFF3A3D40)
val ErrorDark = Color(0xFFC86A57)
val ErrorContainerDark = Color(0xFF4A2620)
val OnErrorContainerDark = Color(0xFFF2D9D1)
val SurfaceDimDark = Color(0xFF1C1411)
val SurfaceBrightDark = Color(0xFF3A2C22)
val SurfaceContainerLowestDark = Color(0xFF140D0B)
val SurfaceContainerLowDark = Color(0xFF211712)
val SurfaceContainerDark = Color(0xFF271C16)
val SurfaceContainerHighDark = Color(0xFF32241C)
val SurfaceContainerHighestDark = Color(0xFF3D2C22)
@@ -0,0 +1,104 @@
package org.modg.bookshelf.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val LightColors = lightColorScheme(
primary = MahoganyLight,
onPrimary = PaperLight,
primaryContainer = MahoganyDeepLight,
onPrimaryContainer = PaperLight,
secondary = GoldLight,
onSecondary = InkLight,
secondaryContainer = GoldSoftLight,
onSecondaryContainer = MahoganyDeepLight,
tertiary = SilverLight,
onTertiary = InkLight,
tertiaryContainer = SilverSoftLight,
onTertiaryContainer = InkLight,
error = ErrorLight,
onError = PaperLight,
errorContainer = ErrorContainerLight,
onErrorContainer = OnErrorContainerLight,
background = PaperLight,
onBackground = InkLight,
surface = PaperLight,
onSurface = InkLight,
surfaceVariant = PaperAltLight,
onSurfaceVariant = InkSoftLight,
outline = SilverLight,
outlineVariant = SilverSoftLight,
scrim = InkLight,
inverseSurface = InkLight,
inverseOnSurface = PaperLight,
inversePrimary = GoldSoftLight,
surfaceTint = MahoganyLight,
surfaceDim = SurfaceDimLight,
surfaceBright = SurfaceBrightLight,
surfaceContainerLowest = SurfaceContainerLowestLight,
surfaceContainerLow = SurfaceContainerLowLight,
surfaceContainer = SurfaceContainerLight,
surfaceContainerHigh = SurfaceContainerHighLight,
surfaceContainerHighest = SurfaceContainerHighestLight,
)
private val DarkColors = darkColorScheme(
primary = MahoganyDark,
onPrimary = PaperTextDark,
primaryContainer = MahoganyDeepDark,
onPrimaryContainer = PaperTextDark,
secondary = GoldDark,
onSecondary = GroundDark,
secondaryContainer = Color(0xFF4A3820),
onSecondaryContainer = GoldDark,
tertiary = SilverDark,
onTertiary = GroundDark,
tertiaryContainer = Color(0xFF3A3F45),
onTertiaryContainer = SilverDark,
error = ErrorDark,
onError = GroundDark,
errorContainer = ErrorContainerDark,
onErrorContainer = OnErrorContainerDark,
background = GroundDark,
onBackground = PaperTextDark,
surface = SurfaceDark,
onSurface = PaperTextDark,
surfaceVariant = SurfaceVariantDark,
onSurfaceVariant = OnSurfaceVariantDark,
outline = OutlineDark,
outlineVariant = OutlineVariantDark,
scrim = GroundDark,
inverseSurface = PaperLight,
inverseOnSurface = InkLight,
inversePrimary = MahoganyLight,
surfaceTint = MahoganyDark,
surfaceDim = SurfaceDimDark,
surfaceBright = SurfaceBrightDark,
surfaceContainerLowest = SurfaceContainerLowestDark,
surfaceContainerLow = SurfaceContainerLowDark,
surfaceContainer = SurfaceContainerDark,
surfaceContainerHigh = SurfaceContainerHighDark,
surfaceContainerHighest = SurfaceContainerHighestDark,
)
/**
* Bookshelf's Material3 theme. Dynamic color is intentionally never wired up
* here — a wallpaper-derived palette would fight the warm-paper-and-mahogany
* identity the SPEC calls for, so there is no dynamicColor parameter at all.
*/
@Composable
fun BookshelfTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
val colorScheme = if (darkTheme) DarkColors else LightColors
MaterialTheme(
colorScheme = colorScheme,
typography = BookshelfTypography,
content = content,
)
}
@@ -0,0 +1,132 @@
package org.modg.bookshelf.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import org.modg.bookshelf.R
// Literata (OFL, bundled in res/font — see LICENSE-Literata-OFL.txt) is the
// serif voice for anything that reads as a title or heading: it's what makes
// this app feel like a shelf of books rather than a generic list app. Body
// and UI copy stay on the system sans so long paragraphs (descriptions,
// notes) stay easy to read at small sizes.
val Literata = FontFamily(
Font(R.font.literata_regular, FontWeight.Normal),
Font(R.font.literata_italic, FontWeight.Normal, FontStyle.Italic),
Font(R.font.literata_medium, FontWeight.Medium),
Font(R.font.literata_medium_italic, FontWeight.Medium, FontStyle.Italic),
Font(R.font.literata_semibold, FontWeight.SemiBold),
Font(R.font.literata_semibold_italic, FontWeight.SemiBold, FontStyle.Italic),
Font(R.font.literata_bold, FontWeight.Bold),
Font(R.font.literata_bold_italic, FontWeight.Bold, FontStyle.Italic),
)
val SystemSans = FontFamily.Default
// Generous line-height throughout per SPEC ("Generous line-height").
val BookshelfTypography = Typography(
displayLarge = TextStyle(
fontFamily = Literata,
fontWeight = FontWeight.SemiBold,
fontSize = 57.sp,
lineHeight = 68.sp,
letterSpacing = (-0.25).sp,
),
displayMedium = TextStyle(
fontFamily = Literata,
fontWeight = FontWeight.SemiBold,
fontSize = 45.sp,
lineHeight = 56.sp,
),
displaySmall = TextStyle(
fontFamily = Literata,
fontWeight = FontWeight.Medium,
fontSize = 36.sp,
lineHeight = 46.sp,
),
headlineLarge = TextStyle(
fontFamily = Literata,
fontWeight = FontWeight.Medium,
fontSize = 32.sp,
lineHeight = 42.sp,
),
headlineMedium = TextStyle(
fontFamily = Literata,
fontWeight = FontWeight.Medium,
fontSize = 28.sp,
lineHeight = 38.sp,
),
headlineSmall = TextStyle(
fontFamily = Literata,
fontWeight = FontWeight.Medium,
fontSize = 24.sp,
lineHeight = 34.sp,
),
titleLarge = TextStyle(
// Topbar / screen titles — the "serif title" the scaffold calls for.
fontFamily = Literata,
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 30.sp,
),
titleMedium = TextStyle(
fontFamily = Literata,
fontWeight = FontWeight.Medium,
fontSize = 18.sp,
lineHeight = 26.sp,
letterSpacing = 0.15.sp,
),
titleSmall = TextStyle(
fontFamily = Literata,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.1.sp,
),
bodyLarge = TextStyle(
fontFamily = SystemSans,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 26.sp,
letterSpacing = 0.5.sp,
),
bodyMedium = TextStyle(
fontFamily = SystemSans,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 22.sp,
letterSpacing = 0.25.sp,
),
bodySmall = TextStyle(
fontFamily = SystemSans,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 18.sp,
letterSpacing = 0.4.sp,
),
labelLarge = TextStyle(
fontFamily = SystemSans,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
labelMedium = TextStyle(
fontFamily = SystemSans,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 18.sp,
letterSpacing = 0.5.sp,
),
labelSmall = TextStyle(
fontFamily = SystemSans,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp,
),
)
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#5C2E23"
android:pathData="M0,0h108v108h-108z" />
</vector>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Three book spines, gold on mahogany, standing side by side. -->
<path
android:fillColor="#D9B45B"
android:pathData="M34,28 h10 v52 h-10 z" />
<path
android:fillColor="#C0932F"
android:pathData="M48,24 h10 v56 h-10 z" />
<path
android:fillColor="#D9B45B"
android:pathData="M62,30 h10 v50 h-10 z" />
<path
android:fillColor="#E8DCC8"
android:pathData="M30,80 h48 v6 h-48 z" />
</vector>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Bookshelf" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/ground_dark</item>
<item name="android:statusBarColor">@color/ground_dark</item>
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Mirrors ui.theme.Color.kt; used only for the pre-Compose XML window theme. -->
<color name="paper_light">#F5EDE0</color>
<color name="ground_dark">#1C1411</color>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Bookshelf</string>
</resources>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. The real Material3 theming happens in Compose
(ui.theme.BookshelfTheme); this XML theme only needs to cover the
window background shown before the first Compose frame. -->
<style name="Theme.Bookshelf" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:windowBackground">@color/paper_light</item>
<item name="android:statusBarColor">@color/paper_light</item>
<item name="android:windowLightStatusBar">true</item>
</style>
</resources>
@@ -0,0 +1,114 @@
package org.modg.bookshelf.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import org.junit.Rule
import org.junit.Test
import org.modg.bookshelf.ui.theme.BookshelfTheme
/**
* Renders the wave-2 component set to PNG on the JVM (no device/emulator).
* Every component gets a light and a dark snapshot so both themes required
* by the SPEC are actually exercised, not just assumed to work.
*
* Run `./gradlew recordPaparazziDebug` to (re)record golden PNGs under
* src/test/snapshots, or `./gradlew verifyPaparazziDebug` to diff against them.
*/
class ComponentGalleryPaparazziTest {
@get:Rule
val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_6)
@Test
fun scaffoldAndDividerLight() = snapshotBothThemes("scaffold") {
BookshelfScaffold(
title = "Bookshelf",
syncStatusBar = { SyncStatusBar(status = SyncStatus.Synced, label = "Synced") },
) { padding ->
PaperSurface(modifier = Modifier.fillMaxWidth().padding(padding)) {
Column(modifier = Modifier.padding(16.dp)) {
Text("A page of paper, with a gold rule below the title bar.")
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
Text("Restrained, not skeuomorphic.")
}
}
}
}
// Only the "no coverUrl" path is snapshotted here: it's the one
// deterministic BookCover state (no network I/O). The loading/error
// states are real code paths (see BookCover.kt's CoverPlaceholder /
// AsyncImagePainter.State.Error branch) but depend on an async Coil
// network load settling inside a single synchronous Paparazzi frame,
// which isn't reliable on a sandboxed JVM test — see BUILD_NOTES.md.
@Test
fun bookCoverPlaceholder() = snapshotBothThemes("book-cover") {
androidx.compose.foundation.layout.Row(
modifier = Modifier.padding(24.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
BookCover(
coverUrl = null,
contentDescription = "No cover on file",
modifier = Modifier.weight(1f),
)
BookCover(
coverUrl = null,
contentDescription = "Another shelf slot",
modifier = Modifier.weight(1f),
)
}
}
@Test
fun buttons() = snapshotBothThemes("buttons") {
Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
PrimaryButton(text = "Save", onClick = {})
SecondaryButton(text = "Skip", onClick = {})
PrimaryButton(text = "Disabled", onClick = {}, enabled = false)
}
}
@Test
fun emptyState() = snapshotBothThemes("empty-state") {
EmptyState(
title = "Your shelves are empty",
message = "Scan a barcode to add your first book.",
action = { PrimaryButton(text = "Scan a book", onClick = {}) },
)
}
@Test
fun syncStatusBarStates() = snapshotBothThemes("sync-status-bar") {
Column(verticalArrangement = Arrangement.spacedBy(1.dp)) {
SyncStatusBar(status = SyncStatus.Synced, label = "Synced • 2m ago")
SyncStatusBar(status = SyncStatus.Syncing, label = "Syncing…")
SyncStatusBar(status = SyncStatus.Offline, label = "Offline — will sync later")
SyncStatusBar(status = SyncStatus.Error, label = "Couldn't reach server")
}
}
/** Snapshots [content] once under the light theme and once under dark. */
private fun snapshotBothThemes(
name: String,
content: @androidx.compose.runtime.Composable () -> Unit,
) {
paparazzi.snapshot(name = "$name-light") {
BookshelfTheme(darkTheme = false) {
PaperSurface { content() }
}
}
paparazzi.snapshot(name = "$name-dark") {
BookshelfTheme(darkTheme = true) {
PaperSurface { content() }
}
}
}
}
+20
View File
@@ -0,0 +1,20 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
//
// AGP 9 has Kotlin support BUILT IN (the org.jetbrains.kotlin.android plugin no
// longer exists / must not be applied). AGP's built-in Kotlin defaults to bundling
// its own Kotlin Gradle Plugin version; bump it here to the version we actually
// want (must match the Compose/serialization plugin versions below).
buildscript {
dependencies {
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.3.21")
classpath("com.google.devtools.ksp:symbol-processing-gradle-plugin:2.3.11")
}
}
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.paparazzi) apply false
}
+13
View File
@@ -0,0 +1,13 @@
# Project-wide Gradle settings.
org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
# AndroidX
android.useAndroidX=true
# Kotlin
kotlin.code.style=official
# Compose (dynamic color intentionally off, see ui.theme)
android.nonTransitiveRClass=true
+83
View File
@@ -0,0 +1,83 @@
[versions]
agp = "9.1.1"
kotlin = "2.3.21"
ksp = "2.3.11"
composeBom = "2026.08.00"
coreKtx = "1.19.0"
activityCompose = "1.13.0"
lifecycle = "2.11.0"
navigationCompose = "2.10.0"
room = "2.8.4"
retrofit = "3.0.0"
retrofitKotlinxSerializationConverter = "1.0.0"
okhttp = "5.5.0"
kotlinxSerializationJson = "1.11.0"
kotlinxCoroutines = "1.11.0"
coil = "3.6.2"
cameraX = "1.6.2"
mlkitBarcodeScanning = "17.3.0"
workManager = "2.11.2"
datastorePreferences = "1.2.1"
accompanistPermissions = "0.37.3"
paparazzi = "2.0.0-alpha05"
junit = "4.13.2"
robolectric = "4.16.1"
androidxTestCore = "1.7.0"
androidxTestExtJunit = "1.3.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }
retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-kotlinx-serialization-converter = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinxSerializationConverter" }
okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "cameraX" }
androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "cameraX" }
androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraX" }
androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "cameraX" }
mlkit-barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlkitBarcodeScanning" }
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePreferences" }
accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistPermissions" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" }
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExtJunit" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
paparazzi = { id = "app.cash.paparazzi", version.ref = "paparazzi" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 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.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# 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/platforms/jvm/plugins-application/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##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || 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
# 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=SC2039,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=SC2039,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" )
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 optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# 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" "$@"
+93
View File
@@ -0,0 +1,93 @@
@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
@rem SPDX-License-Identifier: Apache-2.0
@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. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
: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
+17
View File
@@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Bookshelf"
include(":app")
+110
View File
@@ -0,0 +1,110 @@
# Bookshelf — session handoff
Written 2026-09-06 by the Opus orchestrator, after a sprite restart forced a fresh session.
## Read these first, in order
1. `docs/SPEC.md` — the authoritative product/technical contract. Unchanged and still correct.
Every worker prompt must point at it. Do not restate it; do not let it drift.
2. This file — operational state, what's done, what bit us, what's next.
## Operating model (the user explicitly asked for this — keep it)
The user is on the **$20/mo Pro plan** and wants Opus used sparingly.
- **Opus = orchestrator only.** Write specs, launch workers, verify results, decide.
Do NOT write app code yourself. Do NOT read large files into Opus context.
- **Sonnet = all implementation**, via `claude -p` (NOT the Agent tool — the user asked
for `claude -p` specifically, and it keeps worker output out of the orchestrator's context).
- Read worker output via `logs/<name>.summary` / `jq -r '.result'`, never by cat-ing source.
- Quota status: user reported **62% of the 5h window consumed** at ~19:45 on 09-05.
Worker A alone cost **$2.40 / 85 turns**. Budget accordingly; prefer resuming a
session over restarting one.
### How to launch a worker
```
cd ~/bookshelf && nohup ./tasks/run-task.sh <NAME> ./tasks/<NAME>.txt >/dev/null 2>&1 &
```
`tasks/run-task.sh` is quota-aware: on a usage-limit error it sleeps `POLL` (600s) and
resumes the SAME session rather than restarting, up to `MAX_WALL` (24h), and does not
count quota waits against its 3-strike hard-failure budget. It writes:
`logs/<name>.json` (final result), `.err`, `.sid` (session id), `.state` (progress), `.summary`.
**HAZARD — do not repeat:** never edit `run-task.sh` while workers are running. Bash reads
scripts by byte offset; swapping the file mid-run makes live workers resume inside unrelated
code and can spawn duplicate `claude` processes that burn quota on finished work. If you must
change it, write a NEW file and use that for the next wave.
## Environment
- JDK 21: `~/toolchain/jdk21` (system java is 25 — too new for AGP, do not use it)
- Android SDK: `~/toolchain/android-sdk` (platforms;android-37.0, build-tools;37.0.0, platform-tools)
- **No KVM, no emulator.** Verify only via `./gradlew assembleDebug`, JVM unit tests, and
Paparazzi PNG rendering. Never claim the app was "run".
- PocketBase v0.40.2 service, **127.0.0.1:8090, deliberately NOT internet-exposed**
(no `--http-port`, so the sprite proxy can't reach it). Restart:
`sprite-env services restart pocketbase`. Logs: `/.sprite/logs/services/pocketbase.log`.
- Superuser creds: `server/.dev-credentials` (gitignored).
## STATE: what is DONE
### Wave 1A — server: COMPLETE and verified by the orchestrator (not just self-reported)
`server/` contains `setup-schema.sh` (idempotent), `create-user.sh`, `pb_hooks/main.pb.js`,
`pb_migrations/`, `deploy/` (systemd unit, Dockerfile, compose, backup.sh, README covering
Tailscale vs port-forward+Caddy), `README.md`, `.gitignore`.
Independently re-verified on 09-06 after fixing the service:
| Check | Result |
|---|---|
| anonymous LIST books/shelves/bookcases | **403 / 403 / 403** |
| anonymous self-registration | **403** |
| `/api/health` | 200 |
**`pb_hooks/main.pb.js` is INTENTIONAL, not scope drift.** PocketBase's `listRule` is a row
filter, so anonymous LIST would otherwise return `200 []` instead of an error. The hook forces
403. Keep it; it is why the table above passes. It is auto-loaded by the stock binary.
## STATE: what is NOT done
### Wave 1B — Android scaffold + design system: INCOMPLETE (killed mid-run by the restart)
Present: gradle wrapper, `gradle/libs.versions.toml`, `app/build.gradle.kts`,
`AndroidManifest.xml`, `proguard-rules.pro`, Literata OFL license.
Missing/unverified: ui/theme (Color/Type/Theme), the shared component set, MainActivity,
Paparazzi setup, and **any evidence the build compiles**.
**Its session SURVIVED and is resumable — prefer this over a restart (saves quota):**
`claude -p --model sonnet --permission-mode bypassPermissions --output-format json \`
` --add-dir ~/bookshelf --resume 6823e72a-69c1-486e-ae5a-18abab84529b`
with a "continue where you left off, don't restart" prompt. (Worker A's session, for
reference, is `5e3bd183-252c-4224-99b5-91779761ccbc`.)
First thing the resumed worker must do: get `./gradlew assembleDebug` GREEN. Everything
downstream is blocked on it.
### Waves 2-4 — not started. Prompts not yet written.
- **Wave 2 (parallel, after 1B is green):**
- C — data layer: Room entities/DAOs/DB, PocketBase Retrofit client + auth interceptor,
`SyncEngine` (push-then-pull, LWW, tombstones, client-generated 15-char ids), SettingsStore.
- D — metadata + scanning: Open Library + Google Books merge, ISBN-13 checksum validation,
CameraX + ML Kit continuous scanning.
- **Wave 3 (after C+D):** E — the six screens (setup, library, detail, scan, locations, settings).
- **Wave 4:** F — Paparazzi screenshots for the user to judge the look, release keystore +
signed APK, top-level README, end-to-end sync test against the live PocketBase.
## Gotchas already paid for — do not rediscover
1. **Migration filename ↔ `_migrations` desync.** Worker A renamed `1788636563_created_books.js`
to `...564...` to fix an alphabetical-replay ordering bug (`books` sorted before `shelves`,
breaking the relation). Correct for fresh instances, but the dev DB still had the old name
recorded applied, so PocketBase tried to re-create `books` and crash-looped 9 times.
Fixed via `UPDATE _migrations SET file=...`. **If you ever rename a migration, update that
table too.** DB backup: scratchpad `data.db.bak`.
2. Rule semantics: in PocketBase `""` means PUBLIC, `null` means superuser-only. Confusing these
is exactly how the library would end up world-readable.
3. `claude -p --output-format json` writes its log only at exit; a 0-byte `.json` means the
worker is still running or was killed, not that it failed.
4. System JDK is 25 and will break AGP. Workers must export `JAVA_HOME=~/toolchain/jdk21`
(run-task.sh already does).
## Verification standard (hold workers to this)
Workers self-report optimistically. Before accepting any wave:
- Re-run the security curls above yourself. The user's stated requirement is that this not be
"accessible to everyone in the world"; that check is non-negotiable and cheap.
- Require `assembleDebug` + `test` exit 0, and confirm artifacts exist on disk.
- Treat "I couldn't get Paparazzi working so I skipped screenshots" as a finding to report to
the user, not something to paper over — the user explicitly cares how this looks.
## Open questions for the user (not yet asked — deferred, not forgotten)
- Where the server will actually live (home box vs a sprite) — only affects the deploy README.
- Their two account emails, for `create-user.sh`. Not needed until the app can log in.
+137
View File
@@ -0,0 +1,137 @@
# Bookshelf — authoritative spec
Two-person shared home library. Android app + self-hosted PocketBase.
ALL workers must follow this exactly. Do not invent alternative names.
## Non-negotiables
- Offline-first. Home server is often unreachable (residential NAT). Every read
comes from Room. Every write lands in Room first, syncs later. No screen may
block on network.
- Private. No public registration. Auth required for all data access.
- Server URL is NOT hardcoded; user enters it on first run.
## Repo layout
~/bookshelf/
server/ PocketBase binary(gitignored), pb_migrations/, setup-schema.sh, deploy/
app/ Android Gradle project
docs/ this spec
## Android
- applicationId/namespace: org.modg.bookshelf
- minSdk 26, compileSdk 37, targetSdk 37, JDK 21, Kotlin, Jetpack Compose, Material 3
- SDK at ~/toolchain/android-sdk ; JDK at ~/toolchain/jdk21
- DI: manual `AppContainer` held by Application. NO Hilt/kapt. Room uses KSP.
- Libs: Compose BOM, room(+ksp), retrofit2 + kotlinx-serialization converter,
okhttp logging, coil3 compose, camerax(core/camera2/lifecycle/view),
com.google.mlkit:barcode-scanning, androidx.work runtime-ktx, datastore-preferences,
navigation-compose, lifecycle-viewmodel-compose, accompanist-permissions (or manual)
## Package structure (org.modg.bookshelf.*)
data.local Room: entities, daos, BookshelfDatabase, Converters
data.remote PocketBaseApi (retrofit), dtos, PbAuthInterceptor
data.metadata OpenLibrary + GoogleBooks lookup
data.repo BookRepository, LocationRepository, SyncEngine, AuthRepository
data.prefs SettingsStore (DataStore)
ui.theme Color/Type/Theme
ui.library, ui.detail, ui.scan, ui.locations, ui.settings, ui.setup
ui.nav BookshelfNavHost
## Data model — Room mirrors PocketBase 1:1
IDs: 15-char lowercase alnum, GENERATED CLIENT-SIDE for new records
(PocketBase accepts client-supplied ids on create). Never remap ids after push.
BookEntity(id PK, title, subtitle, authorsJson, isbn13, isbn10, publisher,
publishedDate, pageCount:Int?, description, coverUrl, coverSourceUrl,
shelfId:String?, notes, addedBy, deleted:Boolean, createdAt:Long, updatedAt:Long,
syncState:SyncState, localCoverPath:String?)
BookcaseEntity(id PK, name, note, position:Int, deleted, createdAt, updatedAt, syncState)
ShelfEntity(id PK, bookcaseId, label, position:Int, deleted, createdAt, updatedAt, syncState)
enum SyncState { SYNCED, PENDING_CREATE, PENDING_UPDATE, PENDING_DELETE }
All queries filter `deleted = 0`. Deletion is ALWAYS soft (tombstone) so sync can
propagate it and nothing is silently lost from a shared library.
## PocketBase schema (collections)
bookcases: name(text,req), note(text), position(number), deleted(bool)
shelves: bookcase(relation->bookcases,req,maxSelect 1), label(text,req),
position(number), deleted(bool)
books: title(text,req), subtitle(text), authors(json), isbn13(text), isbn10(text),
publisher(text), published_date(text), page_count(number), description(text),
cover(file,maxSelect 1,image mimes,thumbs 100x150+300x450),
cover_source_url(text), shelf(relation->shelves,maxSelect 1),
notes(text), added_by(relation->users,maxSelect 1), deleted(bool)
All three get autodate created/updated.
Indexes: books(isbn13), books(updated), shelves(updated), bookcases(updated).
API rules — all of list/view/create/update/delete on the three collections:
"@request.auth.id != \"\""
users collection: createRule = null (SUPERUSER ONLY — this is what keeps the
world out), listRule/viewRule = "@request.auth.id != \"\"",
updateRule = "id = @request.auth.id", deleteRule = null.
NOTE: rule "" means PUBLIC in PocketBase; null means superuser-only. Do not confuse.
## Sync design (SyncEngine)
Pull: GET /api/collections/{c}/records?filter=(updated>'{cursor}')&sort=updated
&perPage=200&page=N — paginate to exhaustion. Cursor per collection in
DataStore, stored as PB UTC string. Include tombstones.
Push: records where syncState != SYNCED. PENDING_CREATE -> POST (with our id),
PENDING_UPDATE -> PATCH, PENDING_DELETE -> PATCH {deleted:true}.
On 404 for update/delete: drop local record. On 409/duplicate id: switch to PATCH.
Order: push THEN pull (so our writes come back canonical).
Conflict: last-write-wins on `updated`. Document this in README; do not build
anything cleverer.
Covers: on save, app downloads cover from metadata source and multipart-uploads it
to the book's `cover` file field, so covers survive upstream URL rot. If offline,
store localCoverPath and upload on next sync.
Trigger: app start, manual pull-to-refresh, WorkManager periodic (~6h, network-constrained).
Never let sync failure surface as a crash or a blocking dialog — a quiet status line only.
## Book metadata lookup
Primary Open Library: https://openlibrary.org/api/books?bibkeys=ISBN:{isbn}&format=json&jscmd=data
Fallback Google Books: https://www.googleapis.com/books/v1/volumes?q=isbn:{isbn} (no key)
Cover: https://covers.openlibrary.org/b/isbn/{isbn}-L.jpg else GB imageLinks (force https, zoom=2)
Merge: prefer whichever has a title; fill blanks from the other. Return null if both miss,
and the UI must then offer manual entry pre-filled with the scanned ISBN.
## Barcode scanning
CameraX Preview + ImageAnalysis -> ML Kit BarcodeScanning (EAN_13, EAN_8, UPC_A).
Validate ISBN-13 checksum before lookup; ignore non-book barcodes. Debounce repeats.
Continuous mode: after a save, stay on camera for the next book (shelving a box of
books is the real use case). Show a running "added this session" count.
Handle: camera permission denial, torch toggle, and a manual-ISBN-entry escape hatch.
## Design language — "feels like books"
Warm paper, dark mahogany, gold + silver metallics. Restrained, not skeuomorphic.
Light: paper #F5EDE0, paperAlt #EDE3D2, ink #2B211A, inkSoft #5A4A3D,
mahogany #5C2E23, mahoganyDeep #3E1E17, gold #C0932F, goldSoft #D9B45B,
silver #9CA3AF, silverSoft #C7CCD1
Dark: ground #1C1411, surface #241A15, paperText #E8DCC8, mahogany #7A3E2F,
gold #D9B45B, silver #C7CCD1
Type: serif display (Literata, OFL, bundle the TTF) for titles/headers;
system sans for body/UI. Generous line-height.
Motifs: subtle spine/edge treatments, thin gold hairline rules, gentle paper-grain
on large surfaces. Covers are the hero — let them carry the color.
Both light and dark themes required. Dynamic color OFF (it would fight the palette).
## Screens
setup First run: server URL (+ https scheme validation, trailing-slash strip,
reachability probe), email, password. Clear errors for wrong URL vs bad creds.
library Cover grid (2-3 col adaptive). Search title/author/ISBN. Filter by
bookcase/shelf. Sort title/author/added. Empty state invites first scan.
FAB -> scan. Sync status line.
detail Big cover, title/subtitle/authors/publisher/year/pages/ISBN, description
(collapsible), notes (editable), location picker, edit, soft-delete w/ undo.
scan Camera + reticle; on hit -> bottom sheet w/ fetched book + shelf picker +
Save / Skip. Duplicate-ISBN warning if already owned.
locations Bookcases -> shelves tree. CRUD + reorder. Book counts per shelf.
Tap a shelf -> library filtered to it. "Move books" bulk action.
settings Server, account, sign out, manual sync + last-sync time, book/cover counts.
## Quality bar
- No emulator on this box (no KVM). Verify via: `./gradlew assembleDebug`,
JVM unit tests, and Paparazzi screenshot rendering.
- Unit-test the real logic: ISBN checksum, metadata merge, sync conflict resolution,
DAO queries (Robolectric). Do not write assertion-free tests.
- App must compile and run with NO server configured (setup screen) and must not
crash when the server is unreachable.
+1
View File
@@ -0,0 +1 @@
5e3bd183-252c-4224-99b5-91779761ccbc
View File
+1
View File
@@ -0,0 +1 @@
6823e72a-69c1-486e-ae5a-18abab84529b
View File
+2
View File
@@ -0,0 +1,2 @@
A-server=5e3bd183-252c-4224-99b5-91779761ccbc
B-scaffold=6823e72a-69c1-486e-ae5a-18abab84529b
+14
View File
@@ -0,0 +1,14 @@
# Superuser credentials for the dev instance — never commit these.
.dev-credentials
# The PocketBase binary is platform-specific and large; fetch/build it
# per docs/SPEC.md and deploy/README.md instead of vendoring it in git.
pocketbase
pocketbase.exe
# Runtime data — the whole point of pb_migrations/ is that this is
# reproducible from scratch; the actual database/uploads are not source.
pb_data/
# Local backup artifacts produced by deploy/backup.sh.
deploy/backups/
+56
View File
@@ -0,0 +1,56 @@
## v0.40.2
- Return an error when filter params fallback fails to json serialize and optimized params replacement to execute in a single pass.
- Fixed collection index parsing error for indexes with missing name.
- Minor UI autocomplete optimizations _(prefix match, autocomplete debounce, etc.)_.
- Fixed linter warnings and comment typos.
- Bumped goja and its related dependencies _(regex unescaped dash error fix and base64 optimizations)_.
- Bumped the min Go GitHub action version to 1.27.1 as it includes some [minor `database/sql` and `enconding/json/v2` bug fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.27.1).
## v0.40.1
- Fixes for some reported regressions related to the `encoding/json/v2` update:
- allow mangling invalid UTF8 characters when serializing json data ([#7814](https://github.com/pocketbase/pocketbase/issues/7814))
- fixed OAuth2 providers config merge incorrectly replacing the entire slice ([#7815](https://github.com/pocketbase/pocketbase/issues/7815))
## v0.40.0
- Propagate console command errors and recovered panics to `app.Start()` so that the program can exit with non-zero code while still ensuring that `app.OnTerminate` hook was triggered _(responsible for the app graceful shutdown handling)_.
_⚠️ Note that this could be a slight breaking change in case you are chaining PocketBase commands and relied on the previous `0` exit status for `Command.RunE` returned errors._
_Or in other words, if you have `./pocketbase invalid && someothercommand` and previously relied that `someothercommand` will be always executed then this is no longer the case and you'll have to adjust it or replace `&&` with `;`._
- Added quotes around the default `Content-Disposition` serving filename in case custom name with special characters is provided.
- Added `Cross-Origin-Opener-Policy:same-origin` to the default security response headers.
_This is an extra precaution to prevent tab-nabbing in case custom UI plugins use `target="_blank"` without `rel="noopener"`._
- Added `Record.GetInt64(field)` helper (note that the serializable max safe integer of the `number` field is ~2^53-1).
- Added `Store.Keys()` method that returns a slice with all of the store keys.
- Added new `DELETE /api/logs` endpoint and UI control to delete all logs without changing the `maxDays` retention setting.
- Added new log settings option to limit the max `Log.Data` size that will be saved in the database (default to ~16KB).
_This is an extra precaution for the cases when logging user supplied data without validating it beforehand._
_If the resulting `Log.Data` json is above the limit, it is truncated to the last valid decoded character and an extra `"__pb_truncated__":true` log data entry will be added.`_
_Additionally, for just in case the log message is also truncated at max 8k characters._
- Added new `filesystem` low-level helper methods:
- `filesystem.NewWriter(key, opts)` to allow direct file create from an `io.Reader` value.
- `filesystem.OnNewWriter()` hook to allow listening for new/to-be-created files _(it is not exposed in `core.App` instance for now to avoid introducing breaking changes)_.
- `filesystem.OnDelete()` hook to allow listening for deleted files _(it is not exposed in `core.App` instance for now to avoid introducing breaking changes)_.
- Optimized backups to no longer transaction lock the database during backup generation ([#7799](https://github.com/pocketbase/pocketbase/discussions/7799#discussioncomment-18108244)).
- Updated `modernc.org/sqlite` to 1.57.0 and registered by default the new `_defensive=1` DSN query parameter to enable [SQLite's defensive mode](https://sqlite.org/c3ref/c_dbconfig_defensive.html#sqlitedbconfigdefensive).
- Bumped the min Go version to 1.27.0 and migrated to the new `encoding/json/v2` package.
_⚠️ Please note that Go 1.27.0 retrofitted `encoding/json` to use the v2 package under the hood but unfortunately is not fully backward compatible._
_I recommend to not push blindly an update on production and to test your PocketBase application first locally to see if everything works correctly._
+17
View File
@@ -0,0 +1,17 @@
The MIT License (MIT)
Copyright (c) 2022 - present, Gani Georgiev
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
and associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or
substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+81
View File
@@ -0,0 +1,81 @@
# Bookshelf server
PocketBase backend for the Bookshelf app — see `../docs/SPEC.md` for the
authoritative data model and API rules. This directory contains everything
needed to provision and run it.
## Layout
```
server/
pocketbase PocketBase v0.40.2 binary (gitignored — platform-specific)
pb_data/ SQLite databases + uploaded files (gitignored — runtime state)
pb_migrations/ Schema history, auto-captured — a fresh instance replays
these on first boot and ends up with the full schema
pb_hooks/ JS hook that closes a PocketBase quirk (see below)
setup-schema.sh Idempotent schema/rules provisioning (curl + jq only)
create-user.sh Superuser-driven account creation (no public sign-up)
deploy/ systemd unit, Docker, backups, remote-access guidance
```
## Quickstart (local dev)
PocketBase must already be running (see `deploy/README.md` for how to run
it as a proper service; for a quick local check you can just run the binary
directly: `./pocketbase serve`).
```sh
# 1. Provision the schema + API rules (safe to re-run).
./setup-schema.sh http://127.0.0.1:8090 <superuser-email> <superuser-password>
# 2. Create app accounts — there is no self-registration.
./create-user.sh you@example.com "a strong password" "Your Name"
```
Both scripts also read `PB_URL`/`PB_EMAIL`/`PB_PASS` from the environment,
or fall back to `.dev-credentials` (gitignored, dev-instance-only — see
`.dev-credentials` in this directory if present) if no args are given.
## Schema summary
Three collections — `bookcases`, `shelves`, `books` — each requiring
authentication for every action (list/view/create/update/delete). Deletion
in the app is always a soft `deleted` flag (tombstone), never an actual
record delete, so sync can propagate it — see `SyncEngine` in the Android
app and `docs/SPEC.md`'s Sync design section.
The built-in `users` collection is locked down: `createRule = null` means
**only a superuser can create an account** (via `create-user.sh`); regular
users can view any user (needed to resolve the `added_by` relation) and
update only their own record.
## The pb_hooks quirk
PocketBase's declarative list/search rule acts as a row-level SQL filter,
not a hard gate: an unauthenticated request against a collection whose
`listRule` requires auth still gets **`200 OK` with an empty result**,
rather than an error — because the rule can't be cleanly separated into
"deny the whole request" vs. "just don't return matching rows" (see
[pocketbase/pocketbase#6492](https://github.com/pocketbase/pocketbase/discussions/6492)).
No private data ever leaks this way, but a `200` is still the wrong signal
for "you're not allowed here." `pb_hooks/main.pb.js` adds a small
`onRecordsListRequest` hook that turns that into a `403` for the three app
collections. It's plain JS, auto-loaded by the stock `pocketbase` binary —
no recompilation, no framework — so it ships and deploys exactly like
`pb_migrations/`.
## Verifying a deployment
```sh
# Anonymous requests must be rejected (4xx) for all three collections:
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8090/api/collections/books/records
# -> 403
# Self-registration must be rejected:
curl -s -X POST http://127.0.0.1:8090/api/collections/users/records \
-H 'Content-Type: application/json' -d '{"email":"x@x.com","password":"password123","passwordConfirm":"password123"}'
# -> 403 "Only superusers can perform this action."
```
See `deploy/README.md` for running the server long-term, reaching it from
outside your home network, and backups.
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
#
# create-user.sh — superuser-driven account creation for Bookshelf.
#
# There is no self-registration path (users.createRule = null, see
# setup-schema.sh), so the only way to create an app account is for the
# server owner to run this script as the superuser. Intended for the two
# household accounts (owner + spouse), but works for any number of users.
#
# Usage:
# ./create-user.sh <email> <password> [name]
# PB_URL=https://bookshelf.example.com ./create-user.sh alice@example.com "correct horse battery staple" Alice
#
# PB_URL, PB_EMAIL (superuser), PB_PASS (superuser) come from env, or fall
# back to ./.dev-credentials, same as setup-schema.sh.
set -u -o pipefail
NEW_EMAIL="${1:-}"
NEW_PASS="${2:-}"
NEW_NAME="${3:-}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CREDS_FILE="$SCRIPT_DIR/.dev-credentials"
log() { printf '%s\n' "$*" >&2; }
ok() { printf '\033[32m✓\033[0m %s\n' "$*" >&2; }
fail() { printf '\033[31m✗ ERROR:\033[0m %s\n' "$*" >&2; exit 1; }
command -v curl >/dev/null 2>&1 || fail "curl is required but not installed."
command -v jq >/dev/null 2>&1 || fail "jq is required but not installed."
if [[ -z "$NEW_EMAIL" || -z "$NEW_PASS" ]]; then
fail "Usage: $0 <email> <password> [name]"
fi
if [[ ${#NEW_PASS} -lt 8 ]]; then
fail "Password must be at least 8 characters (PocketBase minimum)."
fi
PB_URL="${PB_URL:-http://127.0.0.1:8090}"
PB_URL="${PB_URL%/}"
PB_EMAIL="${PB_EMAIL:-}"
PB_PASS="${PB_PASS:-}"
if [[ -z "$PB_EMAIL" || -z "$PB_PASS" ]]; then
if [[ -f "$CREDS_FILE" ]]; then
# shellcheck disable=SC1090
source "$CREDS_FILE"
PB_EMAIL="${PB_EMAIL:-${PB_SUPERUSER_EMAIL:-}}"
PB_PASS="${PB_PASS:-${PB_SUPERUSER_PASS:-}}"
fi
fi
[[ -n "$PB_EMAIL" ]] || fail "No superuser email given. Set PB_EMAIL or provide $CREDS_FILE."
[[ -n "$PB_PASS" ]] || fail "No superuser password given. Set PB_PASS or provide $CREDS_FILE."
log "Creating Bookshelf account on $PB_URL"
log " Email: $NEW_EMAIL"
AUTH_RESP="$(curl -s -w '\n%{http_code}' -X POST "$PB_URL/api/collections/_superusers/auth-with-password" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg identity "$PB_EMAIL" --arg password "$PB_PASS" '{identity:$identity, password:$password}')")"
AUTH_BODY="$(printf '%s' "$AUTH_RESP" | sed '$d')"
AUTH_CODE="$(printf '%s' "$AUTH_RESP" | tail -n1)"
[[ "$AUTH_CODE" == "200" ]] || fail "Superuser login failed (HTTP $AUTH_CODE): $AUTH_BODY"
TOKEN="$(printf '%s' "$AUTH_BODY" | jq -r '.token')"
PAYLOAD="$(jq -n \
--arg email "$NEW_EMAIL" \
--arg password "$NEW_PASS" \
--arg name "$NEW_NAME" \
'{email:$email, password:$password, passwordConfirm:$password, name:$name, emailVisibility:true, verified:true}')"
CREATE_RESP="$(curl -s -w '\n%{http_code}' -X POST "$PB_URL/api/collections/users/records" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d "$PAYLOAD")"
CREATE_BODY="$(printf '%s' "$CREATE_RESP" | sed '$d')"
CREATE_CODE="$(printf '%s' "$CREATE_RESP" | tail -n1)"
if [[ "$CREATE_CODE" != "200" ]]; then
fail "Failed to create user (HTTP $CREATE_CODE): $CREATE_BODY"
fi
USER_ID="$(printf '%s' "$CREATE_BODY" | jq -r '.id')"
ok "Created user '$NEW_EMAIL' (id: $USER_ID)"
+30
View File
@@ -0,0 +1,30 @@
# Bookshelf PocketBase server — container image.
#
# The pocketbase binary is already vendored into ./server (this repo does not
# gitignore it out of the image build context — see server/.gitignore, which
# only excludes it from *git*). If you'd rather fetch it fresh, replace the
# COPY below with a curl of the official release for your architecture from
# https://github.com/pocketbase/pocketbase/releases (match the version
# already pinned in server/pocketbase --version, currently v0.40.2).
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /pb
COPY pocketbase /pb/pocketbase
COPY pb_migrations /pb/pb_migrations
COPY pb_hooks /pb/pb_hooks
RUN chmod +x /pb/pocketbase
# pb_data is a volume — see docker-compose.yml. Do not bake data into the
# image; it must survive container recreation.
VOLUME /pb/pb_data
EXPOSE 8090
ENTRYPOINT ["/pb/pocketbase"]
CMD ["serve", "--http=0.0.0.0:8090", "--dir=/pb/pb_data", "--migrationsDir=/pb/pb_migrations", "--hooksDir=/pb/pb_hooks"]
+156
View File
@@ -0,0 +1,156 @@
# Deploying Bookshelf's PocketBase server
This assumes a spare always-on machine at home (a mini PC, NUC, Raspberry Pi,
or an old laptop) running Linux. Pick **one** of the two run methods below —
systemd or Docker — not both.
## 0. Get the files onto the server
Copy the whole `server/` directory (minus `.dev-credentials`, which is
gitignored and dev-only) to the target machine, e.g.:
```sh
rsync -av --exclude .dev-credentials ~/bookshelf/server/ youruser@homeserver:/opt/bookshelf/
```
The `pocketbase` binary is architecture-specific — if your home server isn't
the same CPU architecture as wherever you built/downloaded it, grab the
matching build from https://github.com/pocketbase/pocketbase/releases
(this project is pinned to **v0.40.2**) and drop it in as `/opt/bookshelf/pocketbase`.
## 1. Run it — Option A: systemd (recommended for a bare-metal/VM host)
1. Create a dedicated unprivileged user:
```sh
sudo useradd --system --home /opt/bookshelf --shell /usr/sbin/nologin bookshelf
sudo chown -R bookshelf:bookshelf /opt/bookshelf
```
2. Install the unit:
```sh
sudo cp deploy/bookshelf.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now bookshelf
```
3. Check it's up: `systemctl status bookshelf` and `curl http://127.0.0.1:8090/api/health`.
4. Logs: `journalctl -u bookshelf -f`.
Note the unit binds PocketBase to `127.0.0.1:8090` only — it is **not**
reachable from other machines yet. That's intentional; see step 4.
## 2. Run it — Option B: Docker
```sh
cd deploy
docker compose up -d --build
docker compose logs -f
```
This also only publishes to `127.0.0.1:8090` on the host, for the same
reason. Data persists in the `bookshelf_pb_data` named volume regardless of
container restarts/rebuilds.
## 3. Provision the schema and accounts
Once the server is up and answering on `127.0.0.1:8090`, run the setup
scripts from `server/` (one directory up from here):
```sh
cd /opt/bookshelf
# If this is a brand-new PocketBase data dir, it prints a one-time setup URL
# on first launch (see `journalctl -u bookshelf` or `docker compose logs`) —
# open that in a browser first to create your superuser account.
./setup-schema.sh http://127.0.0.1:8090 <superuser-email> <superuser-password>
# Then create the household accounts — there is no public sign-up:
./create-user.sh owner@example.com "a strong password" "Owner Name"
./create-user.sh spouse@example.com "a different strong password" "Spouse Name"
```
`setup-schema.sh` is idempotent — safe to re-run any time (e.g. after
pulling an updated `server/` if the schema ever changes).
## 4. Reach it from outside your home network
The app needs a URL it can hit from anywhere your phone goes — not just your
home wifi. Two real options:
### Tailscale (recommended)
Install Tailscale on the home server and on your phone, join both to the
same tailnet. The server gets a stable `100.x.y.z` address (or a
MagicDNS name like `homeserver.your-tailnet.ts.net`) reachable from
anywhere, encrypted end-to-end, with **no ports opened on your router**.
- **Pros:** essentially zero attack surface (nothing is exposed to the
public internet at all — not even a login page), no TLS cert management,
works behind CGNAT, five-minute setup.
- **Cons:** both devices need the Tailscale app installed and signed in;
if Tailscale's coordination service has an outage, *new* connections may
be briefly unable to establish (existing ones keep working) — acceptable
for a two-person home library, not for something needing five-nines.
- Point the app's server-URL field at `http://100.x.y.z:8090` (Tailscale's
encryption makes plain HTTP tolerable *inside the tailnet*, but see the
HTTPS note below — using `https://` via Tailscale Serve, next, is easy
enough to just do).
- Even better: use [`tailscale serve`](https://tailscale.com/kb/1312/serve)
to get automatic HTTPS with a real cert on your tailnet domain, so the
app can just always use `https://`:
```sh
sudo tailscale serve --bg 8090
```
### Port-forward + Caddy (reverse proxy with TLS)
Forward a port on your router to the home server, and run
[Caddy](https://caddyserver.com/) in front of PocketBase to terminate TLS
with an automatic Let's Encrypt certificate.
- **Pros:** works with any client, no extra app/agent needed on the phone,
a real public HTTPS URL.
- **Cons:** you're now running an internet-facing service from your home —
bugs in PocketBase, Caddy, or your router's firmware are now a real
attack surface; you need a domain name (or dynamic-DNS if your ISP gives
you a changing IP) for Let's Encrypt's HTTP-01/TLS-ALPN-01 challenge to
work; residential ISPs sometimes block inbound 80/443 or use CGNAT,
which breaks this approach entirely (Tailscale sidesteps that).
Minimal Caddyfile:
```
bookshelf.yourdomain.com {
reverse_proxy 127.0.0.1:8090
}
```
Caddy handles the certificate automatically. Point your router's port
forward at the Caddy host's 443, and the app's server-URL field at
`https://bookshelf.yourdomain.com`.
### HTTPS is not optional
The app sends the account password on every login. **Never** point the app
at a plain `http://` URL that crosses the public internet or an untrusted
network — only plain HTTP over Tailscale (which is already
end-to-end-encrypted at the network layer) is acceptable, and even there,
prefer `tailscale serve` for a real cert. If you go the port-forward route,
Caddy above gets you HTTPS for free — don't skip it.
## 5. Back up regularly
```sh
./deploy/backup.sh
```
See the comments at the top of `backup.sh` for what it does (SQLite-safe
snapshot + uploaded covers, tarball, prune old ones) and the restore
procedure. Wire it into cron:
```sh
crontab -e
# add:
0 3 * * * /opt/bookshelf/deploy/backup.sh >> /var/log/bookshelf-backup.log 2>&1
```
Consider also copying the resulting tarballs off-box (another machine, a
USB drive, cloud storage) — a backup that lives on the same disk as the
data it's backing up doesn't protect against disk failure.
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
#
# backup.sh — SQLite-safe backup of a running PocketBase pb_data directory.
#
# PocketBase's data.db and auxiliary.db are SQLite databases opened in WAL
# mode. Copying the .db files directly with `cp` while the server is running
# can grab an inconsistent snapshot if a checkpoint happens mid-copy. Using
# sqlite3's `.backup` command (or PocketBase's own "backups" API) takes a
# consistent snapshot safely without stopping the server.
#
# This script uses `sqlite3 .backup`, which is the simplest option that needs
# no PocketBase superuser credentials. It backs up pb_data (including
# uploaded files) into timestamped tarballs and prunes old ones.
#
# Usage:
# ./backup.sh [PB_DATA_DIR] [BACKUP_DIR]
# PB_DATA_DIR defaults to ../pb_data (relative to this script)
# BACKUP_DIR defaults to ./backups (relative to this script)
#
# Suggested cron (nightly at 3am, keep last 14):
# 0 3 * * * /opt/bookshelf/deploy/backup.sh >> /var/log/bookshelf-backup.log 2>&1
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PB_DATA_DIR="${1:-$SCRIPT_DIR/../pb_data}"
BACKUP_DIR="${2:-$SCRIPT_DIR/backups}"
KEEP_LAST="${KEEP_LAST:-14}"
log() { printf '[%s] %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$*"; }
fail() { log "ERROR: $*"; exit 1; }
command -v sqlite3 >/dev/null 2>&1 || fail "sqlite3 is required (apt install sqlite3 / apk add sqlite)."
[[ -d "$PB_DATA_DIR" ]] || fail "pb_data directory not found: $PB_DATA_DIR"
TIMESTAMP="$(date -u '+%Y%m%d_%H%M%S')"
WORKDIR="$(mktemp -d)"
trap 'rm -rf "$WORKDIR"' EXIT
mkdir -p "$BACKUP_DIR"
log "Backing up pb_data from $PB_DATA_DIR"
# Consistent SQLite snapshots via the .backup command (safe for a live,
# WAL-mode database — it's the same mechanism `sqlite3 db.sqlite .backup`
# uses, which briefly locks for the copy but never corrupts).
for db in data.db auxiliary.db; do
src="$PB_DATA_DIR/$db"
if [[ -f "$src" ]]; then
log " snapshotting $db"
sqlite3 "$src" ".backup '$WORKDIR/$db'"
fi
done
# Everything else under pb_data that isn't a sqlite file/WAL/SHM (i.e.
# uploaded covers under storage/) gets copied as-is — these are immutable
# blobs once written, so a plain copy is safe.
log " copying uploaded files (storage/)"
if [[ -d "$PB_DATA_DIR/storage" ]]; then
cp -a "$PB_DATA_DIR/storage" "$WORKDIR/storage"
fi
ARCHIVE="$BACKUP_DIR/bookshelf-backup-$TIMESTAMP.tar.gz"
tar -czf "$ARCHIVE" -C "$WORKDIR" .
log "Wrote $ARCHIVE ($(du -h "$ARCHIVE" | cut -f1))"
# Prune old backups, keep the most recent $KEEP_LAST.
mapfile -t old < <(ls -1t "$BACKUP_DIR"/bookshelf-backup-*.tar.gz 2>/dev/null | tail -n +$((KEEP_LAST + 1)))
if [[ ${#old[@]} -gt 0 ]]; then
log "Pruning ${#old[@]} backup(s) older than the last $KEEP_LAST"
rm -f "${old[@]}"
fi
log "Done."
# --- Restore ---------------------------------------------------------------
# 1. Stop the server: sudo systemctl stop bookshelf
# 2. Move aside the live data: mv /opt/bookshelf/pb_data /opt/bookshelf/pb_data.bak
# 3. Extract the chosen backup: mkdir /opt/bookshelf/pb_data && \
# tar -xzf bookshelf-backup-TIMESTAMP.tar.gz -C /opt/bookshelf/pb_data
# 4. Fix ownership: sudo chown -R bookshelf:bookshelf /opt/bookshelf/pb_data
# 5. Start the server again: sudo systemctl start bookshelf
# 6. Once you've confirmed it's healthy, delete pb_data.bak.
+36
View File
@@ -0,0 +1,36 @@
[Unit]
Description=Bookshelf PocketBase server
After=network-online.target
Wants=network-online.target
[Service]
# Adjust to your setup: a dedicated, unprivileged system account.
# Create it once with: sudo useradd --system --home /opt/bookshelf --shell /usr/sbin/nologin bookshelf
User=bookshelf
Group=bookshelf
# All paths below assume the whole ~/bookshelf/server tree (pocketbase binary,
# pb_data/, pb_migrations/, pb_hooks/) is deployed to /opt/bookshelf.
# Adjust WorkingDirectory/ExecStart if you deploy elsewhere.
WorkingDirectory=/opt/bookshelf
ExecStart=/opt/bookshelf/pocketbase serve \
--http=127.0.0.1:8090 \
--dir=/opt/bookshelf/pb_data \
--migrationsDir=/opt/bookshelf/pb_migrations \
--hooksDir=/opt/bookshelf/pb_hooks
Restart=always
RestartSec=5
# Hardening (safe defaults for a single-purpose service; loosen only if
# something concrete breaks).
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/bookshelf/pb_data
CapabilityBoundingSet=
AmbientCapabilities=
[Install]
WantedBy=multi-user.target
+34
View File
@@ -0,0 +1,34 @@
# Bookshelf PocketBase server — Docker Compose deployment.
#
# Build context is the parent `server/` directory (so the Dockerfile can see
# pocketbase, pb_migrations/, pb_hooks/). Run compose from server/deploy/:
#
# cd server/deploy && docker compose up -d
#
# Data lives in the named volume `bookshelf_pb_data`, independent of the
# container lifecycle — `docker compose down` (without -v) never touches it.
#
# The port is published on 127.0.0.1 only, matching bookshelf.service: this
# container is meant to sit behind Tailscale or a Caddy reverse proxy on the
# same host, never exposed directly to the internet (see deploy/README.md).
services:
pocketbase:
build:
context: ..
dockerfile: deploy/Dockerfile
container_name: bookshelf-pocketbase
restart: unless-stopped
ports:
- "127.0.0.1:8090:8090"
volumes:
- bookshelf_pb_data:/pb/pb_data
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8090/api/health"]
interval: 30s
timeout: 5s
retries: 3
volumes:
bookshelf_pb_data:
name: bookshelf_pb_data
+21
View File
@@ -0,0 +1,21 @@
/// <reference path="../pb_data/types.d.ts" />
// PocketBase's declarative API rules act as row-level filters for the
// "list"/"search" action: an unsatisfiable listRule (e.g. requiring auth)
// still returns 200 with an empty result set rather than an error, because
// the rule is just a SQL WHERE clause under the hood. See
// https://github.com/pocketbase/pocketbase/discussions/6492
//
// Bookshelf is a private, two-person library — no anonymous caller should
// ever get a 200 back from these endpoints, even an empty one, since some
// HTTP/JS clients treat "200 with []" as a successful, allowed request.
// This hook makes that explicit: anonymous list/search requests against the
// three app collections are rejected with 403, matching create/update/
// view/delete (which already 400/404 for unauthenticated callers via the
// declarative rules alone).
onRecordsListRequest((e) => {
if (!e.auth) {
throw new ForbiddenError("Authentication required.");
}
e.next();
}, "bookcases", "shelves", "books");
@@ -0,0 +1,113 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": "@request.auth.id != \"\"",
"deleteRule": "@request.auth.id != \"\"",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text1579384326",
"max": 0,
"min": 0,
"name": "name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text3485334036",
"max": 0,
"min": 0,
"name": "note",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"help": "",
"hidden": false,
"id": "number1177347317",
"max": null,
"min": null,
"name": "position",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"help": "",
"hidden": false,
"id": "bool3946532403",
"name": "deleted",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_1015602688",
"indexes": [
"CREATE INDEX `idx_bookcases_updated` ON `bookcases` (`updated`)"
],
"listRule": "@request.auth.id != \"\"",
"name": "bookcases",
"system": false,
"type": "base",
"updateRule": "@request.auth.id != \"\"",
"viewRule": "@request.auth.id != \"\""
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1015602688");
return app.delete(collection);
})
@@ -0,0 +1,112 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": "@request.auth.id != \"\"",
"deleteRule": "@request.auth.id != \"\"",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": false,
"collectionId": "pbc_1015602688",
"help": "",
"hidden": false,
"id": "relation3290364264",
"maxSelect": 1,
"minSelect": 0,
"name": "bookcase",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text245846248",
"max": 0,
"min": 0,
"name": "label",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"help": "",
"hidden": false,
"id": "number1177347317",
"max": null,
"min": null,
"name": "position",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"help": "",
"hidden": false,
"id": "bool3946532403",
"name": "deleted",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_2495675413",
"indexes": [
"CREATE INDEX `idx_shelves_updated` ON `shelves` (`updated`)"
],
"listRule": "@request.auth.id != \"\"",
"name": "shelves",
"system": false,
"type": "base",
"updateRule": "@request.auth.id != \"\"",
"viewRule": "@request.auth.id != \"\""
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_2495675413");
return app.delete(collection);
})
@@ -0,0 +1,26 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("_pb_users_auth_")
// update collection data
unmarshal({
"createRule": null,
"deleteRule": null,
"listRule": "@request.auth.id != \"\"",
"viewRule": "@request.auth.id != \"\""
}, collection)
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("_pb_users_auth_")
// update collection data
unmarshal({
"createRule": "",
"deleteRule": "id = @request.auth.id",
"listRule": "id = @request.auth.id",
"viewRule": "id = @request.auth.id"
}, collection)
return app.save(collection)
})
@@ -0,0 +1,281 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": "@request.auth.id != \"\"",
"deleteRule": "@request.auth.id != \"\"",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text724990059",
"max": 0,
"min": 0,
"name": "title",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text1367709617",
"max": 0,
"min": 0,
"name": "subtitle",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"help": "",
"hidden": false,
"id": "json2383161937",
"maxSize": 0,
"name": "authors",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text2325797416",
"max": 0,
"min": 0,
"name": "isbn13",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text329878418",
"max": 0,
"min": 0,
"name": "isbn10",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text2632504646",
"max": 0,
"min": 0,
"name": "publisher",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text2651326123",
"max": 0,
"min": 0,
"name": "published_date",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"help": "",
"hidden": false,
"id": "number3814243252",
"max": null,
"min": null,
"name": "page_count",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text1843675174",
"max": 0,
"min": 0,
"name": "description",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"help": "",
"hidden": false,
"id": "file2366146245",
"maxSelect": 1,
"maxSize": 0,
"mimeTypes": [
"image/jpeg",
"image/png",
"image/webp",
"image/gif"
],
"name": "cover",
"presentable": false,
"protected": false,
"required": false,
"system": false,
"thumbs": [
"100x150",
"300x450"
],
"type": "file"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text2395634672",
"max": 0,
"min": 0,
"name": "cover_source_url",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"cascadeDelete": false,
"collectionId": "pbc_2495675413",
"help": "",
"hidden": false,
"id": "relation2772917219",
"maxSelect": 1,
"minSelect": 0,
"name": "shelf",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text18589324",
"max": 0,
"min": 0,
"name": "notes",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"cascadeDelete": false,
"collectionId": "_pb_users_auth_",
"help": "",
"hidden": false,
"id": "relation1771793327",
"maxSelect": 1,
"minSelect": 0,
"name": "added_by",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"help": "",
"hidden": false,
"id": "bool3946532403",
"name": "deleted",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_2170393721",
"indexes": [
"CREATE INDEX `idx_books_isbn13` ON `books` (`isbn13`)",
"CREATE INDEX `idx_books_updated` ON `books` (`updated`)"
],
"listRule": "@request.auth.id != \"\"",
"name": "books",
"system": false,
"type": "base",
"updateRule": "@request.auth.id != \"\"",
"viewRule": "@request.auth.id != \"\""
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_2170393721");
return app.delete(collection);
})
+337
View File
@@ -0,0 +1,337 @@
#!/usr/bin/env bash
#
# setup-schema.sh — idempotent PocketBase schema provisioning for Bookshelf.
#
# Creates/updates the `bookcases`, `shelves`, `books` collections exactly per
# docs/SPEC.md, sets every API rule (including locking down self-registration
# on `users`), and creates the required indexes. Safe to re-run any number of
# times against the same instance — it diffs against what already exists.
#
# Requires: bash, curl, jq (no other tooling; portable to any Linux box).
#
# Usage:
# ./setup-schema.sh [PB_URL] [PB_EMAIL] [PB_PASS]
# PB_URL=http://127.0.0.1:8090 PB_EMAIL=admin@example.com PB_PASS=secret ./setup-schema.sh
#
# Args take priority over env vars if both are given.
set -u -o pipefail
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
PB_URL="${1:-${PB_URL:-http://127.0.0.1:8090}}"
PB_EMAIL="${2:-${PB_EMAIL:-}}"
PB_PASS="${3:-${PB_PASS:-}}"
# Strip any trailing slash so URL-joining below is predictable.
PB_URL="${PB_URL%/}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CREDS_FILE="$SCRIPT_DIR/.dev-credentials"
log() { printf '%s\n' "$*" >&2; }
info() { log " $*"; }
ok() { printf '\033[32m✓\033[0m %s\n' "$*" >&2; }
fail() { printf '\033[31m✗ ERROR:\033[0m %s\n' "$*" >&2; exit 1; }
# ---------------------------------------------------------------------------
# Preflight
# ---------------------------------------------------------------------------
command -v curl >/dev/null 2>&1 || fail "curl is required but not installed."
command -v jq >/dev/null 2>&1 || fail "jq is required but not installed."
if [[ -z "$PB_EMAIL" || -z "$PB_PASS" ]]; then
if [[ -f "$CREDS_FILE" ]]; then
info "No PB_EMAIL/PB_PASS given — loading $CREDS_FILE"
# shellcheck disable=SC1090
source "$CREDS_FILE"
PB_EMAIL="${PB_EMAIL:-${PB_SUPERUSER_EMAIL:-}}"
PB_PASS="${PB_PASS:-${PB_SUPERUSER_PASS:-}}"
fi
fi
[[ -n "$PB_EMAIL" ]] || fail "No superuser email given. Pass it as arg 2, set PB_EMAIL, or provide $CREDS_FILE."
[[ -n "$PB_PASS" ]] || fail "No superuser password given. Pass it as arg 3, set PB_PASS, or provide $CREDS_FILE."
log "Bookshelf schema setup"
log " Target: $PB_URL"
log " Superuser: $PB_EMAIL"
log ""
# Reachability check with an actionable error.
if ! curl -sf -o /dev/null --connect-timeout 5 "$PB_URL/api/health"; then
fail "Cannot reach $PB_URL/api/health — is PocketBase running there? Check the URL and that the server is up."
fi
# ---------------------------------------------------------------------------
# Auth
# ---------------------------------------------------------------------------
AUTH_RESP="$(curl -s -w '\n%{http_code}' -X POST "$PB_URL/api/collections/_superusers/auth-with-password" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg identity "$PB_EMAIL" --arg password "$PB_PASS" '{identity:$identity, password:$password}')")"
AUTH_BODY="$(printf '%s' "$AUTH_RESP" | sed '$d')"
AUTH_CODE="$(printf '%s' "$AUTH_RESP" | tail -n1)"
if [[ "$AUTH_CODE" != "200" ]]; then
fail "Superuser login failed (HTTP $AUTH_CODE). Response: $AUTH_BODY
Check PB_EMAIL/PB_PASS (or $CREDS_FILE) and that this account is a _superusers record."
fi
TOKEN="$(printf '%s' "$AUTH_BODY" | jq -r '.token')"
[[ -n "$TOKEN" && "$TOKEN" != "null" ]] || fail "Login succeeded but no token was returned. Response: $AUTH_BODY"
ok "Authenticated as superuser"
AUTH_HEADER="Authorization: Bearer $TOKEN"
# ---------------------------------------------------------------------------
# HTTP helpers
# ---------------------------------------------------------------------------
# Both helpers below print "<http_code>\n<body>" to stdout. Callers must NOT
# rely on a global variable being set as a side effect of a function called
# inside a command substitution — that runs in a subshell, so any such global
# assignment is invisible to the caller once the subshell exits. Encoding the
# code into the captured output itself sidesteps that entirely.
# api_get PATH -> prints "<code>\n<body>"
api_get() {
local path="$1"
curl -s -w '\n%{http_code}' -H "$AUTH_HEADER" "$PB_URL$path" | _reorder_code_first
}
# api_send METHOD PATH JSON_BODY -> prints "<code>\n<body>"
api_send() {
local method="$1" path="$2" body="$3"
curl -s -w '\n%{http_code}' -X "$method" "$PB_URL$path" \
-H "$AUTH_HEADER" -H "Content-Type: application/json" -d "$body" | _reorder_code_first
}
# Reads "<body...>\n<code>" (curl's -w appends code as the final line) and
# re-emits it as "<code>\n<body...>" so callers can cleanly read one line for
# the code and the rest for the body.
_reorder_code_first() {
local all code body
all="$(cat)"
code="$(printf '%s' "$all" | tail -n1)"
body="$(printf '%s' "$all" | sed '$d')"
printf '%s\n%s' "$code" "$body"
}
# split_response VAR_PREFIX RESPONSE — sets ${VAR_PREFIX}_CODE and ${VAR_PREFIX}_BODY
# from a "<code>\n<body>" string, in the *caller's* shell (no subshell).
split_response() {
local prefix="$1" resp="$2"
printf -v "${prefix}_CODE" '%s' "$(printf '%s' "$resp" | head -n1)"
printf -v "${prefix}_BODY" '%s' "$(printf '%s' "$resp" | tail -n +2)"
}
# get_collection_json NAME -> prints collection JSON, or empty string if 404
get_collection_json() {
local name="$1" resp
resp="$(api_get "/api/collections/$name")"
split_response GC "$resp"
if [[ "$GC_CODE" == "200" ]]; then
printf '%s' "$GC_BODY"
elif [[ "$GC_CODE" == "404" ]]; then
printf ''
else
fail "Unexpected response fetching collection '$name' (HTTP $GC_CODE): $GC_BODY"
fi
}
# ---------------------------------------------------------------------------
# Field merging
#
# PocketBase's collection PATCH/POST replaces the *entire* fields array with
# whatever is sent — any field omitted from the payload is dropped. To stay
# idempotent (and to keep each field's existing internal id stable across
# re-runs, which matters for indexes and relations), we merge our desired
# field definitions over whatever fields already exist by matching on name:
# existing fields get their "id" preserved and properties overwritten; new
# fields are appended with no id (PocketBase assigns one on save).
# ---------------------------------------------------------------------------
# merge_fields EXISTING_FIELDS_JSON DESIRED_FIELDS_JSON -> merged fields array
merge_fields() {
local existing="$1" desired="$2"
jq -n --argjson existing "$existing" --argjson desired "$desired" '
($existing // []) as $ex
| ($desired // []) as $des
| ($ex | map({(.name): .id}) | add // {}) as $idByName
| ($ex | map(select(.name == "id"))) as $idField
| $idField + ($des | map(
. as $f
| if ($idByName[$f.name] != null)
then $f + {id: $idByName[$f.name]}
else $f
end
))
'
}
# ---------------------------------------------------------------------------
# Collection definitions (per SPEC.md) — filled in after bookcases/shelves
# ids are known, since relation fields need the target collectionId.
# ---------------------------------------------------------------------------
AUTH_RULE='@request.auth.id != ""'
apply_collection() {
local name="$1" type="$2" desired_fields="$3" indexes_json="$4"
local existing existing_id existing_fields merged payload method path resp
existing="$(get_collection_json "$name")"
if [[ -z "$existing" ]]; then
info "Creating collection '$name'..."
method="POST"
path="/api/collections"
existing_fields="[]"
existing_id=""
else
info "Updating collection '$name'..."
method="PATCH"
path="/api/collections/$name"
existing_fields="$(printf '%s' "$existing" | jq -c '.fields')"
existing_id="$(printf '%s' "$existing" | jq -r '.id')"
fi
merged="$(merge_fields "$existing_fields" "$desired_fields")"
payload="$(jq -n \
--arg name "$name" \
--arg type "$type" \
--argjson fields "$merged" \
--argjson indexes "$indexes_json" \
--arg rule "$AUTH_RULE" \
'{
name: $name,
type: $type,
fields: $fields,
indexes: $indexes,
listRule: $rule,
viewRule: $rule,
createRule: $rule,
updateRule: $rule,
deleteRule: $rule
}')"
resp="$(api_send "$method" "$path" "$payload")"
split_response AC "$resp"
if [[ "$AC_CODE" != "200" ]]; then
fail "Failed to save collection '$name' (HTTP $AC_CODE): $AC_BODY"
fi
ok "Collection '$name' ready (rules = auth-required)"
# Return the collection id via global for callers that need it.
COLLECTION_ID="$(printf '%s' "$AC_BODY" | jq -r '.id')"
# PocketBase auto-writes a pb_migrations/*.js snapshot per collection save,
# named "<unix_seconds>_<action>_<name>.js". Migrations with the same
# second-resolution timestamp are replayed in filename (alphabetical) order
# on a fresh instance, NOT creation order — so e.g. "created_books" can run
# before "created_shelves" even though shelves must exist first for the
# relation field. A 1s pause between collection saves keeps timestamps
# strictly increasing so a fresh instance replays them in the right order.
sleep 1
}
# ---------------------------------------------------------------------------
# 1) bookcases
# ---------------------------------------------------------------------------
BOOKCASES_FIELDS='[
{"name":"name","type":"text","required":true},
{"name":"note","type":"text","required":false},
{"name":"position","type":"number","required":false},
{"name":"deleted","type":"bool","required":false},
{"name":"created","type":"autodate","onCreate":true,"onUpdate":false},
{"name":"updated","type":"autodate","onCreate":true,"onUpdate":true}
]'
BOOKCASES_INDEXES='["CREATE INDEX `idx_bookcases_updated` ON `bookcases` (`updated`)"]'
apply_collection "bookcases" "base" "$BOOKCASES_FIELDS" "$BOOKCASES_INDEXES"
BOOKCASES_ID="$COLLECTION_ID"
# ---------------------------------------------------------------------------
# 2) shelves (relation -> bookcases)
# ---------------------------------------------------------------------------
SHELVES_FIELDS="$(jq -n --arg bcid "$BOOKCASES_ID" '[
{"name":"bookcase","type":"relation","required":true,"collectionId":$bcid,"maxSelect":1,"cascadeDelete":false},
{"name":"label","type":"text","required":true},
{"name":"position","type":"number","required":false},
{"name":"deleted","type":"bool","required":false},
{"name":"created","type":"autodate","onCreate":true,"onUpdate":false},
{"name":"updated","type":"autodate","onCreate":true,"onUpdate":true}
]')"
SHELVES_INDEXES='["CREATE INDEX `idx_shelves_updated` ON `shelves` (`updated`)"]'
apply_collection "shelves" "base" "$SHELVES_FIELDS" "$SHELVES_INDEXES"
SHELVES_ID="$COLLECTION_ID"
# ---------------------------------------------------------------------------
# 3) books (relation -> shelves, relation -> users, file cover)
# ---------------------------------------------------------------------------
USERS_JSON="$(get_collection_json "users")"
[[ -n "$USERS_JSON" ]] || fail "The built-in 'users' collection was not found — is this a fresh/corrupt PocketBase data dir?"
USERS_ID="$(printf '%s' "$USERS_JSON" | jq -r '.id')"
BOOKS_FIELDS="$(jq -n --arg shid "$SHELVES_ID" --arg usid "$USERS_ID" '[
{"name":"title","type":"text","required":true},
{"name":"subtitle","type":"text","required":false},
{"name":"authors","type":"json","required":false},
{"name":"isbn13","type":"text","required":false},
{"name":"isbn10","type":"text","required":false},
{"name":"publisher","type":"text","required":false},
{"name":"published_date","type":"text","required":false},
{"name":"page_count","type":"number","required":false},
{"name":"description","type":"text","required":false},
{"name":"cover","type":"file","required":false,"maxSelect":1,
"mimeTypes":["image/jpeg","image/png","image/webp","image/gif"],
"thumbs":["100x150","300x450"]},
{"name":"cover_source_url","type":"text","required":false},
{"name":"shelf","type":"relation","required":false,"collectionId":$shid,"maxSelect":1,"cascadeDelete":false},
{"name":"notes","type":"text","required":false},
{"name":"added_by","type":"relation","required":false,"collectionId":$usid,"maxSelect":1,"cascadeDelete":false},
{"name":"deleted","type":"bool","required":false},
{"name":"created","type":"autodate","onCreate":true,"onUpdate":false},
{"name":"updated","type":"autodate","onCreate":true,"onUpdate":true}
]')"
BOOKS_INDEXES='[
"CREATE INDEX `idx_books_isbn13` ON `books` (`isbn13`)",
"CREATE INDEX `idx_books_updated` ON `books` (`updated`)"
]'
apply_collection "books" "base" "$BOOKS_FIELDS" "$BOOKS_INDEXES"
# ---------------------------------------------------------------------------
# 4) Lock down `users` — no self-registration, per SPEC.
# createRule = null (superuser-only), list/view = auth-required,
# update = only your own record, delete = null (superuser-only).
# ---------------------------------------------------------------------------
info "Locking down 'users' collection (no self-registration)..."
USERS_PATCH_PAYLOAD='{
"listRule": "@request.auth.id != \"\"",
"viewRule": "@request.auth.id != \"\"",
"createRule": null,
"updateRule": "id = @request.auth.id",
"deleteRule": null
}'
resp="$(api_send "PATCH" "/api/collections/users" "$USERS_PATCH_PAYLOAD")"
split_response UP "$resp"
if [[ "$UP_CODE" != "200" ]]; then
fail "Failed to lock down 'users' collection (HTTP $UP_CODE): $UP_BODY"
fi
ok "'users' collection locked down (createRule=null, deleteRule=null)"
log ""
ok "Schema setup complete."
log ""
log "Next: create accounts with ./create-user.sh (there is no self-registration)."
+41
View File
@@ -0,0 +1,41 @@
You are implementing the SERVER half of the Bookshelf project.
READ FIRST: ~/bookshelf/docs/SPEC.md — it is authoritative. Follow it exactly.
A PocketBase v0.40.2 instance is ALREADY RUNNING at http://127.0.0.1:8090
(managed by `sprite-env services`; restart with `sprite-env services restart pocketbase`,
logs at /.sprite/logs/services/pocketbase.log). Superuser credentials are in
~/bookshelf/server/.dev-credentials. Binary + pb_data are in ~/bookshelf/server/.
Deliver, in ~/bookshelf/server/:
1. setup-schema.sh — idempotent bash (curl+jq only, portable to any Linux box).
Takes PB_URL, PB_EMAIL, PB_PASS from env or args. Creates/updates the three
collections exactly per SPEC, sets ALL API rules, creates the indexes, and
locks down the users collection (createRule=null). Safe to re-run. This is the
script the owner will run on their home server, so make its output clear and its
errors actionable.
2. Run it against the live instance and VERIFY, with real curl calls:
- anonymous GET of books/shelves/bookcases is REJECTED (expect 4xx) — this is the
single most important check in the whole task, do not hand-wave it
- anonymous user-registration attempt is REJECTED
- an authenticated user CAN do full CRUD on all three collections
- relations resolve; file upload to books.cover works (use a small generated PNG)
Print the actual status codes you observed. If any check fails, FIX IT and re-verify.
3. create-user.sh — superuser-driven account creation (the owner and their wife).
No self-registration path may exist.
4. deploy/ — with a README.md that a competent-but-not-expert person can follow:
- bookshelf.service (systemd, runs as non-root, Restart=always)
- Dockerfile + docker-compose.yml
- backup.sh (sqlite-safe backup of pb_data, plus how to restore)
- guidance on reaching a home server from outside: Tailscale (recommended)
vs port-forward + Caddy/TLS. Be concrete about the tradeoffs. HTTPS is
required because the app sends passwords.
5. Confirm pb_migrations/ captured the schema so a fresh instance self-provisions.
Test this: point a throwaway instance at a NEW empty data dir with the same
migrations, start it, confirm collections appear, then delete the throwaway.
6. server/README.md — quickstart.
Do not modify ~/bookshelf/app or ~/bookshelf/docs.
Keep ~/bookshelf/server/.dev-credentials out of version control (write .gitignore).
Finish with a <=25 line report: what you built, the verification status codes, and
anything you had to deviate from in the SPEC and why.
+49
View File
@@ -0,0 +1,49 @@
You are creating the Android project SCAFFOLD + DESIGN SYSTEM for Bookshelf.
READ FIRST: ~/bookshelf/docs/SPEC.md — authoritative, follow exactly.
Environment (already installed, do not reinstall):
JAVA_HOME=~/toolchain/jdk21 (JDK 21) ANDROID_HOME=~/toolchain/android-sdk
Installed: platforms;android-37.0, build-tools;37.0.0, platform-tools
No emulator/KVM available — you CANNOT run the app. Verify by compiling.
Create a Gradle project at ~/bookshelf/app. Deliver:
1. Working Gradle build: settings.gradle.kts, build.gradle.kts, gradle.properties,
gradle/libs.versions.toml (version catalog), app/build.gradle.kts, the Gradle
wrapper (download a wrapper JAR compatible with AGP for compileSdk 37 + JDK 21),
local.properties pointing at the SDK, and a sensible .gitignore.
Pick versions that ACTUALLY RESOLVE — check Maven Central / dl.google.com rather
than guessing. Declare every library from the SPEC in the catalog now, even ones
later waves will use, so downstream workers never touch build files.
2. AndroidManifest with the permissions the SPEC implies (camera, internet) and a
single MainActivity hosting Compose.
3. ui/theme: Color.kt, Type.kt, Theme.kt implementing the SPEC palette for BOTH
light and dark. Dynamic color OFF. Bundle the Literata variable/static TTF in
res/font (download from the Google Fonts GitHub repo, it is OFL — include the
license file). Full Material3 ColorScheme for both modes, mapped thoughtfully:
this app must read as warm paper and mahogany, never as default-Material purple.
4. A small reusable component set in ui/theme or ui/components that later waves will
build every screen from — at minimum: BookshelfScaffold (topbar w/ serif title +
gold hairline rule), PaperSurface, GoldDivider, BookCover (Coil, correct 2:3
aspect, letterpress-ish placeholder when no cover, graceful error state),
PrimaryButton/SecondaryButton, EmptyState, SyncStatusBar. Make these genuinely
nice — the owner explicitly cares that this "feels like books". Restrained and
typographic beats skeuomorphic.
5. Paparazzi configured so Compose renders to PNG on the JVM (no device). Add
screenshot tests for the component set in BOTH light and dark, run
`./gradlew recordPaparazziDebug` (or the equivalent task), and confirm real PNGs
land on disk. If Paparazzi will not cooperate with this AGP/Compose combination
after a genuine effort, say so plainly in your report and fall back to Robolectric
+ Roborazzi; do not silently skip visual verification.
6. A placeholder MainActivity screen that renders the component set, so wave 3 has a
living style reference.
MUST end green: `./gradlew assembleDebug` and `./gradlew test` both pass.
Iterate until they do — a broken build blocks every downstream worker.
Write ~/bookshelf/app/BUILD_NOTES.md recording the EXACT resolved versions (AGP,
Gradle, Kotlin, KSP, Compose BOM, Room, Retrofit, Coil, CameraX, ML Kit, WorkManager,
Paparazzi) and any compatibility traps you hit. Downstream workers depend on this.
Do not modify ~/bookshelf/server or ~/bookshelf/docs.
Finish with a <=25 line report: versions, what builds, Paparazzi status, deviations.
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# run-task.sh <task-name> <prompt-file> [cwd]
# Quota-aware worker runner: survives 5-hour usage-limit caps by sleeping until
# quota refreshes and resuming the SAME session instead of restarting from zero.
set -u
NAME="$1"; PROMPT_FILE="$2"; CWD="${3:-$HOME/bookshelf}"
L="$HOME/bookshelf/logs"; mkdir -p "$L"
LOG="$L/${NAME}.json"; ERR="$L/${NAME}.err"; SIDF="$L/${NAME}.sid"; ST="$L/${NAME}.state"
MAX_WALL="${MAX_WALL:-86400}" # 24h total patience
POLL="${POLL:-600}" # 10 min between quota probes
MAX_HARD_FAILS="${MAX_HARD_FAILS:-3}"
export JAVA_HOME="$HOME/toolchain/jdk21"
export ANDROID_HOME="$HOME/toolchain/android-sdk"
export ANDROID_SDK_ROOT="$ANDROID_HOME"
export PATH="$JAVA_HOME/bin:$ANDROID_HOME/platform-tools:$PATH"
export GRADLE_USER_HOME="$HOME/.gradle"
cd "$CWD" || exit 1
# Stable session id so a killed run can be resumed rather than restarted.
if [ ! -s "$SIDF" ]; then
python3 -c "import uuid;print(uuid.uuid4())" > "$SIDF"
FRESH=1
else
FRESH=0 # sid pre-seeded (recovered) or left by an earlier attempt
fi
SID="$(cat "$SIDF")"
say(){ echo "[$(date -Is)] $NAME: $*" >> "$ST"; }
say "start sid=$SID fresh=$FRESH wall=${MAX_WALL}s poll=${POLL}s"
CONT_PROMPT="Continue the task you were working on, from wherever you left off. \
Your original instructions are earlier in this conversation; re-read them and any \
files you already wrote before doing more work. Do not restart from scratch and do \
not redo completed work. Finish the task and give the final report."
deadline=$(( $(date +%s) + MAX_WALL ))
attempt=0; hard=0; quota_waits=0
while [ "$(date +%s)" -lt "$deadline" ]; do
attempt=$((attempt+1))
if [ "$attempt" -eq 1 ] && [ "$FRESH" -eq 1 ]; then
say "attempt $attempt: fresh (--session-id)"
claude -p --model sonnet --permission-mode bypassPermissions \
--output-format json --add-dir "$HOME/bookshelf" \
--session-id "$SID" < "$PROMPT_FILE" > "$LOG" 2>"$ERR"
else
say "attempt $attempt: resume $SID"
printf '%s' "$CONT_PROMPT" | claude -p --model sonnet \
--permission-mode bypassPermissions --output-format json \
--add-dir "$HOME/bookshelf" --resume "$SID" > "$LOG" 2>"$ERR"
fi
rc=$?
blob="$(cat "$LOG" "$ERR" 2>/dev/null | head -c 20000)"
# 1) quota / rate limit -> wait it out, do NOT burn a hard-fail
if printf '%s' "$blob" | grep -qiE 'usage limit|limit will reset|limit resets|rate_limit_error|rate limit exceeded|429|too many requests|overloaded_error'; then
quota_waits=$((quota_waits+1))
say "QUOTA hit (wait #$quota_waits). sleeping ${POLL}s then probing again."
sleep "$POLL"
continue
fi
# 2) session vanished -> start clean once
if printf '%s' "$blob" | grep -qiE 'no conversation found|session not found|could not resume'; then
say "session $SID unresumable; starting fresh"
python3 -c "import uuid;print(uuid.uuid4())" > "$SIDF"; SID="$(cat "$SIDF")"; FRESH=1; attempt=0
continue
fi
# 3) success
isErr="$(jq -r '.is_error // false' "$LOG" 2>/dev/null)"
if [ "$rc" -eq 0 ] && [ "$isErr" != "true" ]; then
say "SUCCESS after $attempt attempt(s), $quota_waits quota wait(s)"
break
fi
# 4) genuine failure
hard=$((hard+1))
say "hard failure #$hard (rc=$rc is_error=$isErr)"
if [ "$hard" -ge "$MAX_HARD_FAILS" ]; then say "GIVING UP after $hard hard failures"; break; fi
sleep 60
done
[ "$(date +%s)" -ge "$deadline" ] && say "WALL CLOCK EXCEEDED"
{
echo "=== $NAME attempts=$attempt quota_waits=$quota_waits hard_fails=$hard ==="
jq -r '"cost=$" + ((.total_cost_usd//0)|tostring) + " turns=" + ((.num_turns//0)|tostring) + " err=" + ((.is_error//"?")|tostring)' "$LOG" 2>/dev/null
echo "--- result (tail) ---"
jq -r '.result // "no result"' "$LOG" 2>/dev/null | tail -c 1800
} > "$L/${NAME}.summary"