all bulletjournal version 1 completed

This commit is contained in:
Patsy 2026-07-22 21:48:24 +02:00
parent c3937412e7
commit bf6b2820b6
28 changed files with 3467 additions and 0 deletions

1
.python-version Normal file
View file

@ -0,0 +1 @@
test_flask_bullet

47
app/__init__.py Normal file
View file

@ -0,0 +1,47 @@
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from flask_migrate import Migrate
import config
db = SQLAlchemy()
login_manager = LoginManager()
migrate = Migrate()
# Flask-Login configuration
login_manager.login_view = 'auth.login'
login_manager.login_message = 'Please log in to access this page.'
def create_app():
app = Flask(__name__)
app.config.from_object(config.Config)
# Initialize extensions
db.init_app(app)
login_manager.init_app(app)
migrate.init_app(app, db)
# Initialize model classes with db instance
from .models import init_db_models
init_db_models(db)
# Import model classes (stored on init_db_models function)
User = init_db_models.User
UserSettings = init_db_models.UserSettings
DailyLog = init_db_models.DailyLog
DailyLogItem = init_db_models.DailyLogItem
# Flask-Login user loader (must be defined after User class exists)
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
# Register blueprints
from . import routes
app.register_blueprint(routes.bp)
from . import auth
app.register_blueprint(auth.auth_bp)
return app

55
app/auth.py Normal file
View file

@ -0,0 +1,55 @@
from flask import Blueprint, render_template, redirect, url_for, request, flash
from flask_login import login_user, logout_user, login_required
from . import db
from .models import init_db_models
# Import model classes (stored on init_db_models function)
User = init_db_models.User
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
user = User.query.filter_by(username=username).first()
if not user or not user.check_password(password):
flash('Invalid username or password.')
return redirect(url_for('auth.login'))
login_user(user)
return redirect(url_for('main.index'))
return render_template('login.html')
@auth_bp.route('/logout')
@login_required
def logout():
logout_user()
return redirect(url_for('auth.login'))
@auth_bp.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
if User.query.filter_by(username=username).first():
flash('Username already exists.')
return redirect(url_for('auth.register'))
user = User(username=username)
user.set_password(password)
db.session.add(user)
db.session.commit()
flash('Registered successfully. Please log in.')
return redirect(url_for('auth.login'))
return render_template('register.html')

94
app/models.py Normal file
View file

@ -0,0 +1,94 @@
from flask_login import UserMixin
from datetime import datetime
from werkzeug.security import generate_password_hash, check_password_hash
# Sleep quality and status choices for validation
SLEEP_QUALITY_CHOICES = ['poor', 'fair', 'good', 'excellent']
STATUS_CHOICES = ['todo', 'doing', 'done', 'cancelled']
ITEM_TYPE_CHOICES = ['bullet', 'gratitude', 'sleep', 'note', 'task']
def init_db_models(db):
"""Initialize all model classes with the SQLAlchemy instance.
Call this once in create_app() after db is created.
"""
class User(UserMixin, db.Model):
__tablename__ = 'user'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), unique=True, nullable=False)
password_hash = db.Column(db.String(128), nullable=False)
logs = db.relationship('DailyLog', backref='user', lazy=True, cascade='all, delete-orphan')
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
class UserSettings(db.Model):
__tablename__ = 'user_settings'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, unique=True)
theme = db.Column(db.String(10), default='light')
font_size = db.Column(db.Integer, default=18)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class DailyLog(db.Model):
__tablename__ = 'daily_log'
id = db.Column(db.Integer, primary_key=True)
date = db.Column(db.Date, unique=True, nullable=False)
prompt = db.Column(db.Text, nullable=True)
notes = db.Column(db.Text, nullable=True)
gratitude = db.Column(db.JSON, default=list)
bedtime = db.Column(db.String(20), nullable=True)
wake_time = db.Column(db.String(20), nullable=True)
sleep_quality = db.Column(db.String(10), nullable=True) # Enum stored as string
mood = db.Column(db.String(50), nullable=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
items = db.relationship('DailyLogItem', backref='log', lazy=True, cascade='all, delete-orphan')
@property
def gratitude_list(self):
if not self.gratitude:
return ['', '', '']
return self.gratitude + [''] * (3 - len(self.gratitude))
@gratitude_list.setter
def gratitude_list(self, value):
self.gratitude = value[:3] if value else []
def __repr__(self):
return f'<DailyLog {self.date}>'
class DailyLogItem(db.Model):
__tablename__ = 'daily_log_item'
id = db.Column(db.Integer, primary_key=True)
log_id = db.Column(db.Integer, db.ForeignKey('daily_log.id'), nullable=False)
type = db.Column(db.String(20), nullable=False) # Enum stored as string
symbol = db.Column(db.String(10), default='')
text = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
status = db.Column(db.String(10), default='todo') # Enum stored as string
__table_args__ = (
db.Index('ix_log_id', 'log_id'),
db.Index('ix_log_id_status', 'log_id', 'status'),
db.Index('ix_type', 'type'),
)
def __repr__(self):
return f'<DailyLogItem {self.type} {self.log_id}>'
# Store references on module for import by other blueprints
init_db_models.User = User
init_db_models.UserSettings = UserSettings
init_db_models.DailyLog = DailyLog
init_db_models.DailyLogItem = DailyLogItem

365
app/routes.py Normal file
View file

@ -0,0 +1,365 @@
from flask import Blueprint, render_template, redirect, url_for, request, flash, jsonify
from flask_login import login_required, current_user
from datetime import datetime, date
from . import db
from .models import init_db_models
# Import model classes (stored on init_db_models function)
DailyLog = init_db_models.DailyLog
DailyLogItem = init_db_models.DailyLogItem
UserSettings = init_db_models.UserSettings
bp = Blueprint('main', __name__)
# ─── Step 2: Daily Log (main page) ───────────────────────────────────
@bp.route('/')
@login_required
def index():
"""Daily Log screen — main page (Step 2)."""
today = date.today()
log = DailyLog.query.filter_by(date=today, user_id=current_user.id).first()
if not log:
log = DailyLog(date=today, user_id=current_user.id)
db.session.add(log)
db.session.commit()
# Get settings
settings = UserSettings.query.filter_by(user_id=current_user.id).first()
if not settings:
settings = UserSettings(user_id=current_user.id)
db.session.add(settings)
db.session.commit()
return render_template('daily_log.html', log=log, settings=settings)
# ─── Step 2: All Logs ────────────────────────────────────────────────
@bp.route('/logs')
@login_required
def log_list():
"""All Logs screen — list of dates (Step 2)."""
logs = DailyLog.query.filter_by(user_id=current_user.id)\
.order_by(DailyLog.date.desc()).all()
return render_template('log_list.html', logs=logs)
# ─── Step 2: Daily Log Detail (by date) ──────────────────────────────
@bp.route('/logs/<int:log_id>')
@login_required
def log_detail(log_id):
"""Open the Daily Log for a specific date."""
log = DailyLog.query.get_or_404(log_id)
if log.user_id != current_user.id:
flash('Access denied.')
return redirect(url_for('main.log_list'))
return render_template('daily_log.html', log=log, settings=None)
# ─── Step 4: Create / Edit Log ───────────────────────────────────────
@bp.route('/logs/new', methods=['GET', 'POST'])
@login_required
def log_new():
"""Create a new Daily Log entry."""
if request.method == 'POST':
log_date = request.form.get('date')
try:
log_date = datetime.strptime(log_date, '%Y-%m-%d').date()
except ValueError:
flash('Invalid date format.')
return render_template('daily_log_form.html', action='New')
existing = DailyLog.query.filter_by(date=log_date, user_id=current_user.id).first()
if existing:
flash('Log for this date already exists.')
return redirect(url_for('main.log_detail', log_id=existing.id))
log = DailyLog(
date=log_date,
prompt=request.form.get('prompt', ''),
notes=request.form.get('notes', ''),
bedtime=request.form.get('bedtime', ''),
wake_time=request.form.get('wake_time', ''),
sleep_quality=request.form.get('sleep_quality'),
mood=request.form.get('mood', ''),
user_id=current_user.id,
)
db.session.add(log)
db.session.commit()
# Handle gratitude (3 fields)
gratitude = [request.form.get(f'gratitude_{i}', '') for i in range(3)]
log.gratitude = gratitude
# Handle bullets / items
item_types = request.form.getlist('item_type[]')
item_symbols = request.form.getlist('item_symbol[]')
item_texts = request.form.getlist('item_text[]')
item_statuses = request.form.getlist('item_status[]')
for i in range(len(item_types)):
if item_types[i] and item_texts[i].strip():
item = DailyLogItem(
log_id=log.id,
type=item_types[i],
symbol=item_symbols[i] if item_symbols[i] else '',
text=item_texts[i].strip(),
status=item_statuses[i] if item_statuses[i] in ['todo', 'doing', 'done', 'cancelled'] else 'todo',
)
db.session.add(item)
db.session.commit()
flash('Log saved!')
return redirect(url_for('main.log_detail', log_id=log.id))
return render_template('daily_log_form.html', action='New')
@bp.route('/logs/<int:log_id>/edit', methods=['GET', 'POST'])
@login_required
def log_edit(log_id):
"""Edit an existing Daily Log entry."""
log = DailyLog.query.get_or_404(log_id)
if log.user_id != current_user.id:
flash('Access denied.')
return redirect(url_for('main.log_list'))
if request.method == 'POST':
log.prompt = request.form.get('prompt', '')
log.notes = request.form.get('notes', '')
log.bedtime = request.form.get('bedtime', '')
log.wake_time = request.form.get('wake_time', '')
log.sleep_quality = request.form.get('sleep_quality')
log.mood = request.form.get('mood', '')
# Handle gratitude
gratitude = [request.form.get(f'gratitude_{i}', '') for i in range(3)]
log.gratitude = gratitude
# Handle existing items and new items
item_ids = request.form.getlist('item_id[]')
item_types = request.form.getlist('item_type[]')
item_symbols = request.form.getlist('item_symbol[]')
item_texts = request.form.getlist('item_text[]')
item_statuses = request.form.getlist('item_status[]')
# Remove items not in item_ids
existing_ids = [int(i) for i in item_ids if i.isdigit()]
for item in log.items:
if item.id not in existing_ids:
db.session.delete(item)
for i in range(len(item_types)):
if item_ids[i] and item_ids[i].isdigit():
item = DailyLogItem.query.get(int(item_ids[i]))
if item:
item.type = item_types[i]
item.symbol = item_symbols[i] if item_symbols[i] else ''
item.text = item_texts[i].strip()
item.status = item_statuses[i] if item_statuses[i] in ['todo', 'doing', 'done', 'cancelled'] else 'todo'
elif item_types[i] and item_texts[i].strip():
item = DailyLogItem(
log_id=log.id,
type=item_types[i],
symbol=item_symbols[i] if item_symbols[i] else '',
text=item_texts[i].strip(),
status=item_statuses[i] if item_statuses[i] in ['todo', 'doing', 'done', 'cancelled'] else 'todo',
)
db.session.add(item)
db.session.commit()
flash('Log updated!')
return redirect(url_for('main.log_detail', log_id=log.id))
return render_template('daily_log_form.html', log=log, action='Edit')
# ─── Step 4: Add / Update Bullet (AJAX) ──────────────────────────────
@bp.route('/logs/<int:log_id>/items', methods=['POST'])
@login_required
def add_log_item(log_id):
"""Add a bullet/item to a log (AJAX endpoint)."""
log = DailyLog.query.get_or_404(log_id)
if log.user_id != current_user.id:
return jsonify({'success': False, 'error': 'Access denied.'}), 403
data = request.get_json()
item_type = data.get('type', 'bullet')
symbol = data.get('symbol', '')
text = data.get('text', '').strip()
status = data.get('status', 'todo')
if not text:
return jsonify({'success': False, 'error': 'Text is required.'}), 400
item = DailyLogItem(
log_id=log.id,
type=item_type,
symbol=symbol,
text=text,
status=status,
)
db.session.add(item)
db.session.commit()
return jsonify({
'success': True,
'id': item.id,
'type': item.type,
'symbol': item.symbol,
'text': item.text,
'status': item.status,
})
@bp.route('/logs/items/<int:item_id>', methods=['PUT'])
@login_required
def update_log_item(item_id):
"""Update a bullet/item status or text (AJAX endpoint)."""
item = DailyLogItem.query.get_or_404(item_id)
if item.log.user_id != current_user.id:
return jsonify({'success': False, 'error': 'Access denied.'}), 403
data = request.get_json()
if 'status' in data:
item.status = data['status']
if 'text' in data:
item.text = data['text'].strip()
db.session.commit()
return jsonify({'success': True, 'status': item.status, 'text': item.text})
@bp.route('/logs/items/<int:item_id>', methods=['DELETE'])
@login_required
def delete_log_item(item_id):
"""Delete a bullet/item (AJAX endpoint)."""
item = DailyLogItem.query.get_or_404(item_id)
if item.log.user_id != current_user.id:
return jsonify({'success': False, 'error': 'Access denied.'}), 403
db.session.delete(item)
db.session.commit()
return jsonify({'success': True})
# ─── Step 4: Export / Import ─────────────────────────────────────────
@bp.route('/export')
@login_required
def export_logs():
"""Export all logs as JSON (Step 4)."""
logs = DailyLog.query.filter_by(user_id=current_user.id).all()
data = []
for log in logs:
log_data = {
'date': log.date.isoformat(),
'prompt': log.prompt,
'notes': log.notes,
'gratitude': log.gratitude or [],
'bedtime': log.bedtime,
'wake_time': log.wake_time,
'sleep_quality': log.sleep_quality,
'mood': log.mood,
'items': [
{
'type': item.type.value,
'symbol': item.symbol,
'text': item.text,
'status': item.status.value,
}
for item in log.items
],
}
data.append(log_data)
return jsonify(data), 200, {'Content-Type': 'application/json', 'Content-Disposition': 'attachment; filename="logs.json"'}
@bp.route('/import', methods=['POST'])
@login_required
def import_logs():
"""Import logs from JSON file (Step 4)."""
if 'file' not in request.files:
flash('No file uploaded.')
return redirect(url_for('main.settings'))
file = request.files['file']
if not file or not file.filename.endswith('.json'):
flash('Invalid file. Please upload a JSON file.')
return redirect(url_for('main.settings'))
import json as json_module
try:
data = file.read().decode('utf-8')
logs_data = json_module.loads(data)
imported = 0
for log_data in logs_data:
log_date = datetime.strptime(log_data['date'], '%Y-%m-%d').date()
existing = DailyLog.query.filter_by(date=log_date, user_id=current_user.id).first()
if existing:
continue # Skip existing
log = DailyLog(
date=log_date,
prompt=log_data.get('prompt', ''),
notes=log_data.get('notes', ''),
gratitude=log_data.get('gratitude', []),
bedtime=log_data.get('bedtime', ''),
wake_time=log_data.get('wake_time', ''),
sleep_quality=log_data.get('sleep_quality'),
mood=log_data.get('mood', ''),
user_id=current_user.id,
)
db.session.add(log)
db.session.flush()
for item_data in log_data.get('items', []):
item = DailyLogItem(
log_id=log.id,
type=item_data.get('type', 'bullet'),
symbol=item_data.get('symbol', ''),
text=item_data.get('text', ''),
status=item_data.get('status', 'todo'),
)
db.session.add(item)
imported += 1
db.session.commit()
flash(f'{imported} logs imported!')
except Exception as e:
db.session.rollback()
flash(f'Import failed: {str(e)}')
return redirect(url_for('main.settings'))
# ─── Step 4: Settings ────────────────────────────────────────────────
@bp.route('/settings')
@login_required
def settings():
"""Settings screen (Step 2)."""
s = UserSettings.query.filter_by(user_id=current_user.id).first()
if not s:
s = UserSettings(user_id=current_user.id)
db.session.add(s)
db.session.commit()
return render_template('settings.html', settings=s)
@bp.route('/settings/update', methods=['POST'])
@login_required
def update_settings():
"""Update user settings."""
s = UserSettings.query.filter_by(user_id=current_user.id).first()
if not s:
s = UserSettings(user_id=current_user.id)
db.session.add(s)
s.theme = request.form.get('theme', 'light')
s.font_size = int(request.form.get('font_size', 18))
db.session.commit()
flash('Settings saved!')
return redirect(url_for('main.settings'))

662
app/static/style.css Normal file
View file

@ -0,0 +1,662 @@
/* ============================================================
Ivory & Ink Bullet Journal Styles
Based on the Ivory & Ink UI Style Guide
============================================================ */
/* ---------- CSS custom properties (light mode) ---------- */
:root {
/* Page & surfaces */
--page: #FAF8F2;
--page-accent: #F0ECE2;
--surface: #FFFEFA;
--surface-alt: #F6F2E9;
--surface-strong: #E8E1D4;
--border: #D8D1C5;
/* Text */
--text: #242321;
--muted: #6E6A63;
/* Actions */
--primary: #2D2B28;
--primary-hover: #171614;
--secondary: #E8E2D8;
--secondary-hover: #DAD2C5;
/* Supporting accents */
--accent-1: #817A70;
--accent-2: #8C9795;
--accent-3: #B49B73;
--success: #6F806F;
--danger: #8A5D54;
/* Focus */
--focus: #817A70;
/* Spacing */
--space-xxs: 4px;
--space-xs: 8px;
--space-sm: 16px;
--space-md: 24px;
--space-lg: 32px;
--space-xl: 48px;
/* Typography */
--font-body: "Atkinson Hyperlegible", "Segoe UI", Arial, sans-serif;
--font-ui: "Lexend", "Segoe UI", Arial, sans-serif;
--font-size-body: 18px;
--font-size-btn: 16px;
--line-height: 1.6;
}
/* ---------- Dark mode ---------- */
html[data-theme="dark"] {
--page: #020202;
--page-accent: #090806;
--surface: #151411;
--surface-alt: #201E1A;
--surface-strong: #322F28;
--border: #4D4941;
--text: #FAF7EF;
--muted: #C7C1B5;
--primary: #E8E1D4;
--primary-hover: #FFF9ED;
--secondary: #4C4840;
--secondary-hover: #625D53;
--accent-1: #B6ADA0;
--accent-2: #8EA09C;
--accent-3: #C8AD7C;
--success: #9BAE99;
--danger: #C89084;
--focus: #B6ADA0;
}
/* ---------- Reset ---------- */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: var(--font-size-body);
scroll-behavior: smooth;
}
body {
background-color: var(--page);
color: var(--text);
font-family: var(--font-body);
line-height: var(--line-height);
min-height: 100vh;
display: flex;
flex-direction: column;
transition: background-color 0.2s ease, color 0.2s ease;
}
/* ---------- Layout ---------- */
.page-surface {
display: flex;
flex-direction: column;
min-height: 100vh;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
/* Header */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
background-color: var(--surface);
border-bottom: 1px solid var(--border);
}
.page-header__title {
font-family: var(--font-ui);
font-size: 1.25rem;
font-weight: 600;
color: var(--text);
}
.header-nav {
display: flex;
gap: var(--space-xs);
flex-wrap: wrap;
}
/* Main */
.page-main {
flex: 1;
padding: var(--space-md);
}
/* Footer */
.page-footer {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
flex-wrap: wrap;
padding: var(--space-sm) var(--space-md);
background-color: var(--surface);
border-top: 1px solid var(--border);
}
/* ---------- Cards ---------- */
.card {
background-color: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: var(--space-md);
margin-bottom: var(--space-md);
}
.card--full {
width: 100%;
}
.card--future {
opacity: 0.7;
border-style: dashed;
}
.card__eyebrow {
font-family: var(--font-ui);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
margin-bottom: var(--space-xs);
}
.card h2 {
font-family: var(--font-ui);
font-size: 1.5rem;
font-weight: 600;
margin-bottom: var(--space-sm);
color: var(--text);
}
.card h3 {
font-family: var(--font-ui);
font-size: 1.125rem;
font-weight: 500;
margin-bottom: var(--space-xs);
color: var(--text);
}
.future-text {
color: var(--muted);
font-size: 0.9rem;
margin-bottom: var(--space-sm);
}
.future-placeholder {
display: flex;
justify-content: center;
padding: var(--space-md);
color: var(--accent-1);
}
/* ---------- Daily Log Layout (Step 2) ---------- */
.daily-log-layout {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-md);
}
@media (min-width: 768px) {
.daily-log-layout {
grid-template-columns: 2fr 1fr;
}
}
.daily-log__current {
min-width: 0;
}
.daily-log__future {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
/* ---------- Buttons ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-xxs);
padding: var(--space-xs) var(--space-sm);
font-family: var(--font-ui);
font-size: var(--font-size-btn);
font-weight: 500;
line-height: 1;
border: none;
border-radius: 6px;
cursor: pointer;
text-decoration: none;
min-height: 44px;
min-width: 44px;
transition: background-color 0.15s ease, color 0.15s ease;
}
.btn:focus-visible {
outline: 3px solid var(--focus);
outline-offset: 2px;
}
.btn:hover {
filter: brightness(0.95);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background-color: var(--primary);
color: var(--surface);
}
.btn-primary:hover {
background-color: var(--primary-hover);
}
.btn-secondary {
background-color: var(--secondary);
color: var(--text);
}
.btn-secondary:hover {
background-color: var(--secondary-hover);
}
.btn-icon {
background: transparent;
color: var(--muted);
padding: var(--space-xxs) var(--space-xs);
font-size: 1.25rem;
min-width: 36px;
min-height: 36px;
}
.btn-icon:hover {
color: var(--danger);
background-color: var(--surface-alt);
}
.btn-sm {
font-size: 0.875rem;
padding: var(--space-xxs) var(--space-xs);
min-height: 36px;
}
.btn-block {
width: 100%;
}
/* ---------- Forms ---------- */
.form-group {
margin-bottom: var(--space-sm);
}
.form-group label {
display: block;
font-family: var(--font-ui);
font-size: 0.9rem;
font-weight: 500;
margin-bottom: var(--space-xxs);
color: var(--text);
}
.form-help {
display: block;
font-size: 0.8rem;
color: var(--muted);
margin-top: var(--space-xxs);
}
input[type="text"],
input[type="password"],
input[type="email"],
input[type="number"],
input[type="file"],
textarea,
select {
width: 100%;
padding: var(--space-xs) var(--space-sm);
font-family: var(--font-body);
font-size: 1rem;
line-height: var(--line-height);
color: var(--text);
background-color: var(--surface-alt);
border: 1px solid var(--border);
border-radius: 6px;
transition: border-color 0.15s ease;
}
input:focus,
textarea:focus,
select:focus {
outline: none;
border-color: var(--primary);
outline: 3px solid var(--focus);
outline-offset: 1px;
}
textarea {
resize: vertical;
min-height: 60px;
}
/* ---------- Bullets Section ---------- */
.bullets-section {
margin: var(--space-sm) 0;
}
.bullets-list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
margin-bottom: var(--space-xs);
}
.bullet-item {
background-color: var(--surface-alt);
border: 1px solid var(--border);
border-radius: 6px;
padding: var(--space-xs);
}
.bullet-item__row {
display: flex;
gap: var(--space-xxs);
align-items: center;
flex-wrap: wrap;
}
.bullet-item__type,
.bullet-item__symbol,
.bullet-item__status {
width: auto;
min-width: 60px;
padding: var(--space-xxs) var(--space-xs);
font-size: 0.85rem;
}
.bullet-item__text {
flex: 1;
min-width: 120px;
}
/* ---------- Gratitude ---------- */
.gratitude-section {
margin: var(--space-sm) 0;
}
.gratitude-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-xs);
}
@media (min-width: 480px) {
.gratitude-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.gratitude-item {
display: flex;
flex-direction: column;
gap: var(--space-xxs);
}
.gratitude-item label {
font-family: var(--font-ui);
font-size: 0.85rem;
color: var(--muted);
}
.gratitude-item input {
padding: var(--space-xxs) var(--space-xs);
}
/* ---------- Sleep ---------- */
.sleep-section {
margin: var(--space-sm) 0;
}
.sleep-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-xs);
}
@media (min-width: 480px) {
.sleep-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.sleep-item {
display: flex;
flex-direction: column;
gap: var(--space-xxs);
}
.sleep-item label {
font-family: var(--font-ui);
font-size: 0.85rem;
color: var(--muted);
}
.sleep-item input,
.sleep-item select {
padding: var(--space-xxs) var(--space-xs);
}
/* ---------- Mood ---------- */
.mood-section {
margin: var(--space-sm) 0;
}
.mood-selector {
display: flex;
flex-direction: column;
gap: var(--space-xxs);
}
.mood-selector label {
font-family: var(--font-ui);
font-size: 0.9rem;
color: var(--text);
}
.mood-selector select {
padding: var(--space-xs) var(--space-sm);
}
/* ---------- Logs List ---------- */
.logs-list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.log-list-item {
display: flex;
gap: var(--space-sm);
padding: var(--space-sm);
background-color: var(--surface-alt);
border: 1px solid var(--border);
border-radius: 6px;
text-decoration: none;
color: var(--text);
transition: background-color 0.15s ease;
}
.log-list-item:hover,
.log-list-item:focus-visible {
background-color: var(--surface-strong);
}
.log-list-item:focus-visible {
outline: 3px solid var(--focus);
outline-offset: 2px;
}
.log-list-item__date {
flex-shrink: 0;
font-family: var(--font-ui);
font-size: 0.85rem;
color: var(--muted);
min-width: 70px;
}
.log-list-item__preview {
flex: 1;
min-width: 0;
}
.log-list-item__prompt {
font-size: 0.9rem;
color: var(--text);
margin-bottom: var(--space-xxs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.log-list-item__meta {
display: flex;
gap: var(--space-sm);
font-size: 0.8rem;
color: var(--muted);
}
/* ---------- Settings ---------- */
.settings-fieldset {
border: 1px solid var(--border);
border-radius: 6px;
padding: var(--space-sm);
margin-bottom: var(--space-sm);
}
.settings-fieldset legend {
font-family: var(--font-ui);
font-size: 1rem;
font-weight: 600;
color: var(--text);
padding: 0 var(--space-xxs);
}
.import-form {
margin-top: var(--space-sm);
}
/* ---------- Flash Messages ---------- */
.flash-message {
padding: var(--space-xs) var(--space-sm);
border-radius: 6px;
margin-bottom: var(--space-sm);
font-size: 0.9rem;
}
.flash-message.error {
background-color: var(--danger);
color: var(--surface);
border: 1px solid var(--border);
}
.flash-message.success {
background-color: var(--success);
color: var(--surface);
border: 1px solid var(--border);
}
.flash-message.info {
background-color: var(--accent-2);
color: var(--text);
border: 1px solid var(--border);
}
/* ---------- Empty State ---------- */
.empty-state {
text-align: center;
padding: var(--space-lg);
color: var(--muted);
}
.empty-state a {
color: var(--primary);
text-decoration: underline;
}
/* ---------- Page Footer Text ---------- */
.page-footer-text {
text-align: center;
padding: var(--space-sm);
font-size: 0.85rem;
color: var(--muted);
}
.page-footer-text a {
color: var(--primary);
}
/* ---------- Typography Helpers ---------- */
.text-muted {
color: var(--muted);
}
.text-small {
font-size: 0.85rem;
}
/* ---------- Accessibility ---------- */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Focus visible for keyboard navigation */
a:focus-visible,
button:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
outline: 3px solid var(--focus);
outline-offset: 2px;
}
/* High contrast / zoom support */
@media (zoom: 200%) {
body {
font-size: 2em;
}
}
/* ---------- Screen reader only ---------- */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

56
app/templates/base.html Normal file
View file

@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Bullet Journal{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:wght@400;700&family=Lexend:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div class="page-surface">
<header class="page-header">
<h1 class="page-header__title">Bullet Journal</h1>
{% if current_user.is_authenticated %}
<nav class="header-nav">
<a href="{{ url_for('main.index') }}" class="btn btn-secondary btn-sm">Today</a>
<a href="{{ url_for('main.log_list') }}" class="btn btn-secondary btn-sm">All Logs</a>
<a href="{{ url_for('main.settings') }}" class="btn btn-secondary btn-sm">Settings</a>
<a href="{{ url_for('auth.logout') }}" class="btn btn-secondary btn-sm">Logout</a>
</nav>
{% endif %}
</header>
<main class="page-main">
{% block content %}{% endblock %}
</main>
{% if current_user.is_authenticated %}
<footer class="page-footer">
<a href="{{ url_for('main.index') }}" class="btn btn-secondary">Today</a>
<a href="{{ url_for('main.log_list') }}" class="btn btn-secondary">All Logs</a>
<a href="{{ url_for('main.export_logs') }}" class="btn btn-secondary">Export JSON</a>
<a href="{{ url_for('main.settings') }}" class="btn btn-secondary">Settings</a>
</footer>
{% endif %}
</div>
<script>
// Theme toggle helper
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}
// Load saved theme or default to light
(function() {
const saved = localStorage.getItem('theme');
if (saved) {
setTheme(saved);
}
})();
</script>
</body>
</html>

View file

@ -0,0 +1,308 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Daily Log - {{ log.date.strftime('%B %d, %Y') }} - Bullet Journal</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:wght@400;700&family=Lexend:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div class="page-surface">
<!-- Header -->
<header class="page-header">
<h1 class="page-header__title">Bullet Journal</h1>
<nav class="header-nav">
<a href="{{ url_for('main.log_list') }}" class="btn btn-secondary btn-sm">All Logs</a>
<a href="{{ url_for('main.settings') }}" class="btn btn-secondary btn-sm">Settings</a>
<a href="{{ url_for('auth.logout') }}" class="btn btn-secondary btn-sm">Logout</a>
</nav>
</header>
<!-- Main Content -->
<main class="page-main">
<div class="daily-log-layout">
<!-- Left Column: Current Day -->
<section class="daily-log__current">
<article class="card">
<p class="card__eyebrow">Daily Log</p>
<h2>{{ log.date.strftime('%B %d, %Y') }}</h2>
<!-- Prompt -->
<div class="form-group">
<label for="prompt">Prompt</label>
<textarea id="prompt" name="prompt" rows="3" aria-describedby="prompt-help">{{ log.prompt or '' }}</textarea>
<span id="prompt-help" class="form-help">What's on your mind today?</span>
</div>
<!-- Notes -->
<div class="form-group">
<label for="notes">Notes</label>
<textarea id="notes" name="notes" rows="4" aria-describedby="notes-help">{{ log.notes or '' }}</textarea>
<span id="notes-help" class="form-help">Free-form notes for the day.</span>
</div>
<!-- Bullets / Items -->
<div class="bullets-section">
<h3>Bullets & Insights</h3>
<div id="bullets-list" class="bullets-list">
{% for item in log.items %}
<div class="bullet-item" data-item-id="{{ item.id }}">
<div class="bullet-item__row">
<select class="bullet-item__type" aria-label="Item type" data-item-id="{{ item.id }}">
<option value="bullet" {% if item.type == 'bullet' %}selected{% endif %}>Bullet</option>
<option value="task" {% if item.type == 'task' %}selected{% endif %}>Task</option>
<option value="note" {% if item.type == 'note' %}selected{% endif %}>Note</option>
</select>
<select class="bullet-item__symbol" aria-label="Symbol" data-item-id="{{ item.id }}">
<option value="•" {% if item.symbol == '' %}selected{% endif %}></option>
<option value="-" {% if item.symbol == '-' %}selected{% endif %}>-</option>
<option value="o" {% if item.symbol == 'o' %}selected{% endif %}>o</option>
<option value="X" {% if item.symbol == 'X' %}selected{% endif %}>X</option>
</select>
<input type="text" class="bullet-item__text" value="{{ item.text }}" placeholder="Add a bullet..." aria-label="Bullet text" data-item-id="{{ item.id }}">
<select class="bullet-item__status" aria-label="Status" data-item-id="{{ item.id }}">
<option value="todo" {% if item.status == 'todo' %}selected{% endif %}>Todo</option>
<option value="doing" {% if item.status == 'doing' %}selected{% endif %}>Doing</option>
<option value="done" {% if item.status == 'done' %}selected{% endif %}>Done</option>
<option value="cancelled" {% if item.status == 'cancelled' %}selected{% endif %}>Cancelled</option>
</select>
<button class="btn btn-icon btn-delete" data-item-id="{{ item.id }}" aria-label="Delete bullet">&times;</button>
</div>
</div>
{% endfor %}
</div>
<button class="btn btn-secondary" id="add-bullet-btn">+ Add Bullet</button>
</div>
<!-- Gratitude -->
<div class="gratitude-section">
<h3>Gratitude (3 things)</h3>
<div class="gratitude-grid">
{% set grat = log.gratitude_list %}
<div class="gratitude-item">
<label for="gratitude_0">1.</label>
<input type="text" id="gratitude_0" name="gratitude_0" value="{{ grat[0] if grat else '' }}" placeholder="I'm grateful for..." aria-label="Gratitude 1">
</div>
<div class="gratitude-item">
<label for="gratitude_1">2.</label>
<input type="text" id="gratitude_1" name="gratitude_1" value="{{ grat[1] if grat and grat|length > 1 else '' }}" placeholder="I'm grateful for..." aria-label="Gratitude 2">
</div>
<div class="gratitude-item">
<label for="gratitude_2">3.</label>
<input type="text" id="gratitude_2" name="gratitude_2" value="{{ grat[2] if grat and grat|length > 2 else '' }}" placeholder="I'm grateful for..." aria-label="Gratitude 3">
</div>
</div>
</div>
<!-- Sleep -->
<div class="sleep-section">
<h3>Sleep</h3>
<div class="sleep-grid">
<div class="sleep-item">
<label for="bedtime">Bedtime</label>
<input type="text" id="bedtime" name="bedtime" value="{{ log.bedtime or '' }}" placeholder="e.g., 23:00" aria-label="Bedtime">
</div>
<div class="sleep-item">
<label for="wake_time">Wake time</label>
<input type="text" id="wake_time" name="wake_time" value="{{ log.wake_time or '' }}" placeholder="e.g., 07:00" aria-label="Wake time">
</div>
<div class="sleep-item">
<label for="sleep_quality">Sleep quality</label>
<select id="sleep_quality" name="sleep_quality" aria-label="Sleep quality">
<option value="">Select...</option>
<option value="poor" {% if log.sleep_quality == 'poor' %}selected{% endif %}>Poor</option>
<option value="fair" {% if log.sleep_quality == 'fair' %}selected{% endif %}>Fair</option>
<option value="good" {% if log.sleep_quality == 'good' %}selected{% endif %}>Good</option>
<option value="excellent" {% if log.sleep_quality == 'excellent' %}selected{% endif %}>Excellent</option>
</select>
</div>
</div>
</div>
<!-- Mood -->
<div class="mood-section">
<h3>Mood</h3>
<div class="mood-selector">
<label for="mood">How are you feeling?</label>
<select id="mood" name="mood" aria-label="Mood">
<option value="">Select mood...</option>
<option value="great" {% if log.mood == 'great' %}selected{% endif %}>Great</option>
<option value="good" {% if log.mood == 'good' %}selected{% endif %}>Good</option>
<option value="okay" {% if log.mood == 'okay' %}selected{% endif %}>Okay</option>
<option value="bad" {% if log.mood == 'bad' %}selected{% endif %}>Bad</option>
<option value="terrible" {% if log.mood == 'terrible' %}selected{% endif %}>Terrible</option>
</select>
</div>
</div>
<!-- Save Button -->
<button type="button" class="btn btn-primary btn-block" id="save-log-btn">Save Log</button>
</article>
</section>
<!-- Right Column: Coming Soon -->
<aside class="daily-log__future">
<article class="card card--future">
<p class="card__eyebrow">Coming Soon</p>
<h3>Calendar</h3>
<p class="future-text">Monthly grid view for quick navigation between logs.</p>
<div class="future-placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
</div>
</article>
<article class="card card--future">
<p class="card__eyebrow">Coming Soon</p>
<h3>Task List</h3>
<p class="future-text">Cross-day task management with priorities and deadlines.</p>
<div class="future-placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
<path d="M9 11l3 3L22 4"></path>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
</svg>
</div>
</article>
</aside>
</div>
</main>
<!-- Footer -->
<footer class="page-footer">
<a href="{{ url_for('main.log_list') }}" class="btn btn-secondary">All Logs</a>
<a href="{{ url_for('main.export_logs') }}" class="btn btn-secondary">Export JSON</a>
<a href="{{ url_for('main.settings') }}" class="btn btn-secondary">Settings</a>
</footer>
</div>
<script>
// Add new bullet
document.getElementById('add-bullet-btn').addEventListener('click', function() {
const list = document.getElementById('bullets-list');
const newItem = document.createElement('div');
newItem.className = 'bullet-item';
newItem.dataset.itemId = '';
newItem.innerHTML = `
<div class="bullet-item__row">
<select class="bullet-item__type" aria-label="Item type">
<option value="bullet" selected>Bullet</option>
<option value="task">Task</option>
<option value="note">Note</option>
</select>
<select class="bullet-item__symbol" aria-label="Symbol">
<option value="•" selected></option>
<option value="-">-</option>
<option value="o">o</option>
<option value="X">X</option>
</select>
<input type="text" class="bullet-item__text" placeholder="Add a bullet..." aria-label="Bullet text">
<select class="bullet-item__status" aria-label="Status">
<option value="todo" selected>Todo</option>
<option value="doing">Doing</option>
<option value="done">Done</option>
<option value="cancelled">Cancelled</option>
</select>
<button class="btn btn-icon btn-delete" aria-label="Delete bullet">&times;</button>
</div>
`;
list.appendChild(newItem);
newItem.querySelector('.bullet-item__text').focus();
});
// Delete bullet
document.addEventListener('click', function(e) {
if (e.target.classList.contains('btn-delete')) {
const itemId = e.target.dataset.itemId;
if (itemId) {
fetch('/logs/items/' + itemId, { method: 'DELETE' })
.then(response => response.json())
.then(data => {
if (data.success) {
e.target.closest('.bullet-item').remove();
}
});
} else {
e.target.closest('.bullet-item').remove();
}
}
});
// Save log
document.getElementById('save-log-btn').addEventListener('click', function() {
const btn = this;
btn.textContent = 'Saving...';
btn.disabled = true;
// Collect bullets data
const bullets = [];
document.querySelectorAll('.bullet-item').forEach(function(item) {
bullets.push({
id: item.dataset.itemId || '',
type: item.querySelector('.bullet-item__type').value,
symbol: item.querySelector('.bullet-item__symbol').value,
text: item.querySelector('.bullet-item__text').value,
status: item.querySelector('.bullet-item__status').value
});
});
// Build form data
const formData = new FormData();
formData.append('date', '{{ log.date.isoformat() }}');
formData.append('prompt', document.getElementById('prompt').value);
formData.append('notes', document.getElementById('notes').value);
formData.append('bedtime', document.getElementById('bedtime').value);
formData.append('wake_time', document.getElementById('wake_time').value);
formData.append('sleep_quality', document.getElementById('sleep_quality').value);
formData.append('mood', document.getElementById('mood').value);
for (let i = 0; i < 3; i++) {
formData.append('gratitude_' + i, document.getElementById('gratitude_' + i).value);
}
bullets.forEach(function(bullet, index) {
formData.append('item_id[]', bullet.id);
formData.append('item_type[]', bullet.type);
formData.append('item_symbol[]', bullet.symbol);
formData.append('item_text[]', bullet.text);
formData.append('item_status[]', bullet.status);
});
fetch('/logs/{{ log.id }}/edit', {
method: 'POST',
body: formData
})
.then(response => {
btn.textContent = 'Save Log';
btn.disabled = false;
if (response.ok) {
window.location.reload();
} else {
alert('Failed to save log.');
}
})
.catch(error => {
btn.textContent = 'Save Log';
btn.disabled = false;
alert('Failed to save log: ' + error.message);
});
});
// Auto-save on input change
document.querySelectorAll('input, textarea, select').forEach(function(el) {
if (el.id !== 'save-log-btn') {
el.addEventListener('change', function() {
// Trigger save on field change
document.getElementById('save-log-btn').click();
});
}
});
</script>
</body>
</html>

View file

@ -0,0 +1,77 @@
{% extends "base.html" %}
{% block title %}{{ action }} Daily Log{% endblock %}
{% block content %}
<article class="daily-log-form" aria-labelledby="form-heading">
<header class="daily-log-form__header">
<h1 id="form-heading">{{ action }} Daily Log</h1>
</header>
<form class="daily-log-form__form" method="POST" action="{{ url_for('main.log_new') }}" aria-label="Daily log form">
<div class="form-group">
<label for="date">Date</label>
<input type="date" id="date" name="date" value="{{ log.date.isoformat() if log else '' }}" required>
</div>
<div class="form-group">
<label for="prompt">Daily Prompt</label>
<textarea id="prompt" name="prompt" rows="3" placeholder="What's on your mind today?">{{ log.prompt or '' }}</textarea>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea id="notes" name="notes" rows="4" placeholder="Jot down your thoughts...">{{ log.notes or '' }}</textarea>
</div>
<div class="form-group">
<label>Gratitude (3 things)</label>
<div class="gratitude-list">
{% if log %}
{% set grat_list = log.gratitude_list %}
<input type="text" name="gratitude_0" value="{{ grat_list[0] if grat_list else '' }}" placeholder="I am grateful for...">
<input type="text" name="gratitude_1" value="{{ grat_list[1] if grat_list | length > 1 else '' }}" placeholder="I am grateful for...">
<input type="text" name="gratitude_2" value="{{ grat_list[2] if grat_list | length > 2 else '' }}" placeholder="I am grateful for...">
{% else %}
<input type="text" name="gratitude_0" placeholder="I am grateful for...">
<input type="text" name="gratitude_1" placeholder="I am grateful for...">
<input type="text" name="gratitude_2" placeholder="I am grateful for...">
{% endif %}
</div>
</div>
<div class="form-group">
<label>Sleep</label>
<div class="sleep-row">
<div>
<label for="bedtime">Bedtime</label>
<input type="text" id="bedtime" name="bedtime" value="{{ log.bedtime if log else '' }}" placeholder="22:30">
</div>
<div>
<label for="wake_time">Wake Time</label>
<input type="text" id="wake_time" name="wake_time" value="{{ log.wake_time if log else '' }}" placeholder="06:45">
</div>
</div>
<div class="form-group">
<label for="sleep_quality">Sleep Quality</label>
<select id="sleep_quality" name="sleep_quality">
<option value="">— Select —</option>
<option value="poor" {% if log and log.sleep_quality == 'poor' %}selected{% endif %}>Poor</option>
<option value="fair" {% if log and log.sleep_quality == 'fair' %}selected{% endif %}>Fair</option>
<option value="good" {% if log and log.sleep_quality == 'good' %}selected{% endif %}>Good</option>
<option value="excellent" {% if log and log.sleep_quality == 'excellent' %}selected{% endif %}>Excellent</option>
</select>
</div>
</div>
<div class="form-group">
<label for="mood">Mood</label>
<input type="text" id="mood" name="mood" value="{{ log.mood if log else '' }}" placeholder="How are you feeling?">
</div>
<footer class="daily-log-form__footer">
<button type="submit" class="btn-primary">{{ 'Save' if action == 'Edit' else 'Create' }} Log</button>
<a href="{{ url_for('main.log_list') }}" class="btn-secondary">Cancel</a>
</footer>
</form>
</article>
{% endblock %}

View file

@ -0,0 +1,67 @@
{% extends "base.html" %}
{% block title %}Log Detail - Bullet Journal{% endblock %}
{% block content %}
<div class="card card--full">
<p class="card__eyebrow">Log Entry</p>
<h2>{{ log.date.strftime('%A, %B %d, %Y') }}</h2>
<div class="form-group">
<label>Prompt</label>
<p>{{ log.prompt or 'No prompt set.' }}</p>
</div>
<div class="form-group">
<label>Notes</label>
<p>{{ log.notes or 'No notes.' }}</p>
</div>
<div class="bullets-section">
<h3>Bullets & Insights</h3>
{% if log.items %}
<div class="bullets-list">
{% for item in log.items %}
<div class="bullet-item">
<span class="bullet-item__type">{{ item.type }}</span>
<span class="bullet-item__symbol">{{ item.symbol }}</span>
<span class="bullet-item__text">{{ item.text }}</span>
<span class="bullet-item__status">{{ item.status }}</span>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted">No bullets yet.</p>
{% endif %}
</div>
{% if log.gratitude %}
<div class="gratitude-section">
<h3>Gratitude</h3>
<ul>
{% for g in log.gratitude %}
<li>{{ g }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if log.bedtime or log.wake_time or log.sleep_quality %}
<div class="sleep-section">
<h3>Sleep</h3>
<p>Bedtime: {{ log.bedtime or 'N/A' }}</p>
<p>Wake Time: {{ log.wake_time or 'N/A' }}</p>
<p>Quality: {{ log.sleep_quality or 'N/A' }}</p>
</div>
{% endif %}
{% if log.mood %}
<div class="mood-section">
<h3>Mood</h3>
<p>{{ log.mood }}</p>
</div>
{% endif %}
<a href="{{ url_for('main.log_edit', log_id=log.id) }}" class="btn btn-primary btn-block">Edit Log</a>
</div>
{% endblock %}

View file

@ -0,0 +1,152 @@
{% extends "base.html" %}
{% block title %}{{ action }} Log - Bullet Journal{% endblock %}
{% block content %}
<div class="card card--full">
<p class="card__eyebrow">{{ action }}</p>
<h2>{{ action }} Log Entry</h2>
<form method="POST" action="{{ url_for('main.log_new') }}" id="log-form">
<div class="form-group">
<label for="date">Date</label>
<input type="text" id="date" name="date" value="{{ log.date.strftime('%Y-%m-%d') if log else '' }}" placeholder="YYYY-MM-DD" required>
</div>
<div class="form-group">
<label for="prompt">Daily Prompt</label>
<textarea id="prompt" name="prompt" rows="3" placeholder="What's on your mind today?">{{ log.prompt or '' }}</textarea>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea id="notes" name="notes" rows="4" placeholder="Free-form notes...">{{ log.notes or '' }}</textarea>
</div>
<div class="bullets-section">
<h3>Bullets & Insights</h3>
<div class="bullets-list" id="bullets-list">
{% if log %}
{% for item in log.items %}
<div class="bullet-item" data-item-id="{{ item.id }}">
<div class="bullet-item__row">
<select name="item_type[]" class="bullet-item__type">
<option value="bullet" {% if item.type == 'bullet' %}selected{% endif %}>Bullet</option>
<option value="task" {% if item.type == 'task' %}selected{% endif %}>Task</option>
<option value="note" {% if item.type == 'note' %}selected{% endif %}>Note</option>
</select>
<select name="item_symbol[]" class="bullet-item__symbol">
<option value="•" {% if item.symbol == '' %}selected{% endif %}></option>
<option value="-" {% if item.symbol == '-' %}selected{% endif %}>-</option>
<option value="○" {% if item.symbol == '' %}selected{% endif %}></option>
</select>
<input type="text" name="item_text[]" class="bullet-item__text" value="{{ item.text }}" placeholder="Add a bullet point...">
<select name="item_status[]" class="bullet-item__status">
<option value="todo" {% if item.status == 'todo' %}selected{% endif %}>Todo</option>
<option value="doing" {% if item.status == 'doing' %}selected{% endif %}>Doing</option>
<option value="done" {% if item.status == 'done' %}selected{% endif %}>Done</option>
<option value="cancelled" {% if item.status == 'cancelled' %}selected{% endif %}>Cancelled</option>
</select>
<input type="hidden" name="item_id[]" value="{{ item.id }}">
</div>
</div>
{% endfor %}
{% endif %}
</div>
<button type="button" class="btn btn-secondary btn-sm" onclick="addBullet()">+ Add Bullet</button>
</div>
<div class="gratitude-section">
<h3>Gratitude</h3>
<div class="gratitude-grid">
<div class="gratitude-item">
<label for="gratitude_0">1. I'm grateful for...</label>
<input type="text" id="gratitude_0" name="gratitude_0" value="{{ log.gratitude_list[0] if log and log.gratitude_list else '' }}" placeholder="Item 1">
</div>
<div class="gratitude-item">
<label for="gratitude_1">2. I'm grateful for...</label>
<input type="text" id="gratitude_1" name="gratitude_1" value="{{ log.gratitude_list[1] if log and log.gratitude_list else '' }}" placeholder="Item 2">
</div>
<div class="gratitude-item">
<label for="gratitude_2">3. I'm grateful for...</label>
<input type="text" id="gratitude_2" name="gratitude_2" value="{{ log.gratitude_list[2] if log and log.gratitude_list else '' }}" placeholder="Item 3">
</div>
</div>
</div>
<div class="sleep-section">
<h3>Sleep</h3>
<div class="sleep-grid">
<div class="sleep-item">
<label for="bedtime">Bedtime</label>
<input type="text" id="bedtime" name="bedtime" value="{{ log.bedtime or '' }}" placeholder="e.g., 11:00 PM">
</div>
<div class="sleep-item">
<label for="wake_time">Wake Time</label>
<input type="text" id="wake_time" name="wake_time" value="{{ log.wake_time or '' }}" placeholder="e.g., 7:00 AM">
</div>
<div class="sleep-item">
<label for="sleep_quality">Sleep Quality</label>
<select id="sleep_quality" name="sleep_quality">
<option value="">Select...</option>
<option value="poor" {% if log and log.sleep_quality == 'poor' %}selected{% endif %}>Poor</option>
<option value="fair" {% if log and log.sleep_quality == 'fair' %}selected{% endif %}>Fair</option>
<option value="good" {% if log and log.sleep_quality == 'good' %}selected{% endif %}>Good</option>
<option value="excellent" {% if log and log.sleep_quality == 'excellent' %}selected{% endif %}>Excellent</option>
</select>
</div>
</div>
</div>
<div class="mood-section">
<h3>Mood</h3>
<div class="mood-selector">
<label for="mood">How are you feeling?</label>
<select id="mood" name="mood">
<option value="">Select mood...</option>
<option value="amazing" {% if log and log.mood == 'amazing' %}selected{% endif %}>Amazing</option>
<option value="good" {% if log and log.mood == 'good' %}selected{% endif %}>Good</option>
<option value="okay" {% if log and log.mood == 'okay' %}selected{% endif %}>Okay</option>
<option value="bad" {% if log and log.mood == 'bad' %}selected{% endif %}>Bad</option>
<option value="terrible" {% if log and log.mood == 'terrible' %}selected{% endif %}>Terrible</option>
</select>
</div>
</div>
<button type="submit" class="btn btn-primary btn-block">Save Log</button>
</form>
</div>
<script>
function addBullet() {
const list = document.getElementById('bullets-list');
const newItem = document.createElement('div');
newItem.className = 'bullet-item';
newItem.dataset.itemId = '';
newItem.innerHTML = `
<div class="bullet-item__row">
<select name="item_type[]" class="bullet-item__type">
<option value="bullet" selected>Bullet</option>
<option value="task">Task</option>
<option value="note">Note</option>
</select>
<select name="item_symbol[]" class="bullet-item__symbol">
<option value="•" selected></option>
<option value="-">-</option>
<option value="○"></option>
</select>
<input type="text" name="item_text[]" class="bullet-item__text" placeholder="Add a bullet point...">
<select name="item_status[]" class="bullet-item__status">
<option value="todo" selected>Todo</option>
<option value="doing">Doing</option>
<option value="done">Done</option>
<option value="cancelled">Cancelled</option>
</select>
<input type="hidden" name="item_id[]" value="">
</div>
`;
list.appendChild(newItem);
newItem.querySelector('.bullet-item__text').focus();
}
</script>
{% endblock %}

View file

@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}All Logs - Bullet Journal{% endblock %}
{% block content %}
<div class="card card--full">
<p class="card__eyebrow">Archive</p>
<h2>All Logs</h2>
{% if logs %}
<div class="logs-list">
{% for log in logs %}
<a href="{{ url_for('main.log_detail', log_id=log.id) }}" class="log-list-item">
<div class="log-list-item__date">{{ log.date.strftime('%m/%d') }}</div>
<div class="log-list-item__preview">
<p class="log-list-item__prompt">{{ log.prompt or 'No prompt' }}</p>
<div class="log-list-item__meta">
<span>{{ log.mood or 'No mood' }}</span>
<span>{{ log.sleep_quality or 'No sleep data' }}</span>
<span>{{ log.items|length }} items</span>
</div>
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<p>No logs yet.</p>
<a href="{{ url_for('main.index') }}">Create your first log entry!</a>
</div>
{% endif %}
</div>
{% endblock %}

153
app/templates/index.html Normal file
View file

@ -0,0 +1,153 @@
{% extends "base.html" %}
{% block title %}Today's Log - Bullet Journal{% endblock %}
{% block content %}
<div class="card card--full">
<p class="card__eyebrow">Today</p>
<h2>{{ log.date.strftime('%A, %B %d, %Y') }}</h2>
<form method="POST" action="{{ url_for('main.log_edit', log_id=log.id) }}">
<!-- Prompt -->
<div class="form-group">
<label for="prompt">Daily Prompt</label>
<textarea id="prompt" name="prompt" rows="3" placeholder="What's on your mind today?">{{ log.prompt or '' }}</textarea>
<span class="form-help">Set the intention for your day.</span>
</div>
<!-- Notes -->
<div class="form-group">
<label for="notes">Notes</label>
<textarea id="notes" name="notes" rows="4" placeholder="Free-form notes...">{{ log.notes or '' }}</textarea>
</div>
<!-- Bullets Section -->
<div class="bullets-section">
<h3>Bullets & Insights</h3>
<div class="bullets-list" id="bullets-list">
{% for item in log.items %}
<div class="bullet-item" data-item-id="{{ item.id }}">
<div class="bullet-item__row">
<select name="item_type[]" class="bullet-item__type" aria-label="Item type">
<option value="bullet" {% if item.type == 'bullet' %}selected{% endif %}>Bullet</option>
<option value="task" {% if item.type == 'task' %}selected{% endif %}>Task</option>
<option value="note" {% if item.type == 'note' %}selected{% endif %}>Note</option>
</select>
<select name="item_symbol[]" class="bullet-item__symbol" aria-label="Symbol">
<option value="•" {% if item.symbol == '' %}selected{% endif %}></option>
<option value="-" {% if item.symbol == '-' %}selected{% endif %}>-</option>
<option value="○" {% if item.symbol == '' %}selected{% endif %}></option>
</select>
<input type="text" name="item_text[]" class="bullet-item__text" value="{{ item.text }}" placeholder="Add a bullet point..." aria-label="Bullet text">
<select name="item_status[]" class="bullet-item__status" aria-label="Status">
<option value="todo" {% if item.status == 'todo' %}selected{% endif %}>Todo</option>
<option value="doing" {% if item.status == 'doing' %}selected{% endif %}>Doing</option>
<option value="done" {% if item.status == 'done' %}selected{% endif %}>Done</option>
<option value="cancelled" {% if item.status == 'cancelled' %}selected{% endif %}>Cancelled</option>
</select>
<input type="hidden" name="item_id[]" value="{{ item.id }}">
</div>
</div>
{% endfor %}
</div>
<button type="button" class="btn btn-secondary btn-sm" onclick="addBullet()">+ Add Bullet</button>
</div>
<!-- Gratitude Section -->
<div class="gratitude-section">
<h3>Gratitude</h3>
<div class="gratitude-grid">
<div class="gratitude-item">
<label for="gratitude_0">1. I'm grateful for...</label>
<input type="text" id="gratitude_0" name="gratitude_0" value="{{ log.gratitude_list[0] if log.gratitude_list else '' }}" placeholder="Item 1">
</div>
<div class="gratitude-item">
<label for="gratitude_1">2. I'm grateful for...</label>
<input type="text" id="gratitude_1" name="gratitude_1" value="{{ log.gratitude_list[1] if log.gratitude_list else '' }}" placeholder="Item 2">
</div>
<div class="gratitude-item">
<label for="gratitude_2">3. I'm grateful for...</label>
<input type="text" id="gratitude_2" name="gratitude_2" value="{{ log.gratitude_list[2] if log.gratitude_list else '' }}" placeholder="Item 3">
</div>
</div>
</div>
<!-- Sleep Section -->
<div class="sleep-section">
<h3>Sleep</h3>
<div class="sleep-grid">
<div class="sleep-item">
<label for="bedtime">Bedtime</label>
<input type="text" id="bedtime" name="bedtime" value="{{ log.bedtime or '' }}" placeholder="e.g., 11:00 PM">
</div>
<div class="sleep-item">
<label for="wake_time">Wake Time</label>
<input type="text" id="wake_time" name="wake_time" value="{{ log.wake_time or '' }}" placeholder="e.g., 7:00 AM">
</div>
<div class="sleep-item">
<label for="sleep_quality">Sleep Quality</label>
<select id="sleep_quality" name="sleep_quality">
<option value="">Select...</option>
<option value="poor" {% if log.sleep_quality == 'poor' %}selected{% endif %}>Poor</option>
<option value="fair" {% if log.sleep_quality == 'fair' %}selected{% endif %}>Fair</option>
<option value="good" {% if log.sleep_quality == 'good' %}selected{% endif %}>Good</option>
<option value="excellent" {% if log.sleep_quality == 'excellent' %}selected{% endif %}>Excellent</option>
</select>
</div>
</div>
</div>
<!-- Mood -->
<div class="mood-section">
<h3>Mood</h3>
<div class="mood-selector">
<label for="mood">How are you feeling?</label>
<select id="mood" name="mood">
<option value="">Select mood...</option>
<option value="amazing" {% if log.mood == 'amazing' %}selected{% endif %}>Amazing</option>
<option value="good" {% if log.mood == 'good' %}selected{% endif %}>Good</option>
<option value="okay" {% if log.mood == 'okay' %}selected{% endif %}>Okay</option>
<option value="bad" {% if log.mood == 'bad' %}selected{% endif %}>Bad</option>
<option value="terrible" {% if log.mood == 'terrible' %}selected{% endif %}>Terrible</option>
</select>
</div>
</div>
<!-- Save Button -->
<button type="submit" class="btn btn-primary btn-block">Save Log</button>
</form>
</div>
<script>
function addBullet() {
const list = document.getElementById('bullets-list');
const newItem = document.createElement('div');
newItem.className = 'bullet-item';
newItem.dataset.itemId = '';
newItem.innerHTML = `
<div class="bullet-item__row">
<select name="item_type[]" class="bullet-item__type" aria-label="Item type">
<option value="bullet" selected>Bullet</option>
<option value="task">Task</option>
<option value="note">Note</option>
</select>
<select name="item_symbol[]" class="bullet-item__symbol" aria-label="Symbol">
<option value="•" selected></option>
<option value="-">-</option>
<option value="○"></option>
</select>
<input type="text" name="item_text[]" class="bullet-item__text" placeholder="Add a bullet point..." aria-label="Bullet text">
<select name="item_status[]" class="bullet-item__status" aria-label="Status">
<option value="todo" selected>Todo</option>
<option value="doing">Doing</option>
<option value="done">Done</option>
<option value="cancelled">Cancelled</option>
</select>
<input type="hidden" name="item_id[]" value="">
</div>
`;
list.appendChild(newItem);
newItem.querySelector('.bullet-item__text').focus();
}
</script>
{% endblock %}

View file

@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}All Logs - Bullet Journal{% endblock %}
{% block content %}
<div class="card card--full">
<p class="card__eyebrow">Archive</p>
<h2>All Logs</h2>
{% if logs %}
<div class="logs-list">
{% for log in logs %}
<a href="{{ url_for('main.log_detail', log_id=log.id) }}" class="log-list-item">
<div class="log-list-item__date">{{ log.date.strftime('%m/%d') }}</div>
<div class="log-list-item__preview">
<p class="log-list-item__prompt">{{ log.prompt or 'No prompt' }}</p>
<div class="log-list-item__meta">
<span>{{ log.mood or 'No mood' }}</span>
<span>{{ log.sleep_quality or 'No sleep data' }}</span>
<span>{{ log.items|length }} items</span>
</div>
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<p>No logs yet.</p>
<a href="{{ url_for('main.index') }}">Create your first log entry!</a>
</div>
{% endif %}
</div>
{% endblock %}

52
app/templates/login.html Normal file
View file

@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - Bullet Journal</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:wght@400;700&family=Lexend:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div class="page-surface">
<header class="page-header">
<h1 class="page-header__title">Bullet Journal</h1>
</header>
<main class="page-main">
<article class="card">
<p class="card__eyebrow">Sign in</p>
<h2>Welcome back</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<p class="flash-message {{ category }}">{{ message }}</p>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('auth.login') }}" autocomplete="on">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus aria-describedby="username-help">
<span id="username-help" class="form-help">Enter your registered username.</span>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required aria-describedby="password-help">
<span id="password-help" class="form-help">Enter your password.</span>
</div>
<button type="submit" class="btn btn-primary">Sign in</button>
</form>
</article>
<p class="page-footer-text">Don't have an account? <a href="{{ url_for('auth.register') }}">Register here</a>.</p>
</main>
</div>
</body>
</html>

View file

@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Register - Bullet Journal</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:wght@400;700&family=Lexend:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div class="page-surface">
<header class="page-header">
<h1 class="page-header__title">Bullet Journal</h1>
</header>
<main class="page-main">
<article class="card">
<p class="card__eyebrow">Create account</p>
<h2>Get started</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<p class="flash-message {{ category }}">{{ message }}</p>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('auth.register') }}" autocomplete="on">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus aria-describedby="username-help">
<span id="username-help" class="form-help">Choose a unique username.</span>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required minlength="8" aria-describedby="password-help">
<span id="password-help" class="form-help">At least 8 characters.</span>
</div>
<button type="submit" class="btn btn-primary">Create account</button>
</form>
</article>
<p class="page-footer-text">Already have an account? <a href="{{ url_for('auth.login') }}">Sign in here</a>.</p>
</main>
</div>
</body>
</html>

View file

@ -0,0 +1,61 @@
{% extends "base.html" %}
{% block title %}Settings - Bullet Journal{% endblock %}
{% block content %}
<div class="card card--full">
<p class="card__eyebrow">Preferences</p>
<h2>Settings</h2>
<form method="POST" action="{{ url_for('main.update_settings') }}">
<fieldset class="settings-fieldset">
<legend>Appearance</legend>
<div class="form-group">
<label for="theme">Theme</label>
<select id="theme" name="theme">
<option value="light" {% if settings.theme == 'light' %}selected{% endif %}>Light (Ivory)</option>
<option value="dark" {% if settings.theme == 'dark' %}selected{% endif %}>Dark (Charcoal)</option>
</select>
<span class="form-help">Choose your preferred color scheme.</span>
</div>
<div class="form-group">
<label for="font_size">Font Size: {{ settings.font_size }}px</label>
<input type="range" id="font_size" name="font_size" min="14" max="24" value="{{ settings.font_size }}" oninput="document.getElementById('font_size_label').textContent = this.value + 'px'">
<span class="form-help" id="font_size_label">{{ settings.font_size }}px</span>
</div>
</fieldset>
<button type="submit" class="btn btn-primary btn-block">Save Settings</button>
</form>
</div>
<div class="card card--full">
<p class="card__eyebrow">Data</p>
<h2>Import / Export</h2>
<div class="form-group">
<a href="{{ url_for('main.export_logs') }}" class="btn btn-secondary btn-block">Export All Logs as JSON</a>
<span class="form-help">Download a JSON file of all your bullet journal entries.</span>
</div>
<form method="POST" action="{{ url_for('main.import_logs') }}" enctype="multipart/form-data" class="import-form">
<div class="form-group">
<label for="file">Import JSON File</label>
<input type="file" id="file" name="file" accept=".json">
<span class="form-help">Upload a previously exported JSON file to restore your logs.</span>
</div>
<button type="submit" class="btn btn-secondary btn-block">Import Logs</button>
</form>
</div>
<div class="card card--future">
<p class="card__eyebrow">Coming Soon</p>
<h3>Account Settings</h3>
<p class="future-text">Password change and account management will be available soon.</p>
<div class="future-placeholder">
<span>🔒</span>
</div>
</div>
{% endblock %}

176
cline.md Normal file
View file

@ -0,0 +1,176 @@
Bullet Journal App Flask StepbyStep Build Guide
Using Flask 3.x, Python 3.12+, and SQLite. Follow each section, copy the commands into a terminal and run them in order.
1. Environment Setup
Install core dependencies
pip install Flask Flask-SQLAlchemy Flask-Login Flask-Migrate
Generate a requirements.txt (optional but handy)
pip freeze > requirements.txt
2. Project Structure
bullet-journal-flask/
├── app/
│ ├── __init__.py
│ ├── models.py
│ ├── routes.py
│ ├── templates/
│ │ ├── base.html
│ │ ├── index.html
│ │ ├── entry_list.html
│ │ ├── entry_detail.html
│ │ └── entry_form.html
│ └── static/
│ └── style.css
├── config.py
├── run.py
└── requirements.txt
3. Configure Flask & SQLite
config.py add the following (youll copy this into a file):
import os
basedir = os.path.abspath(os.path.dirname(__file__))
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-key'
SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'app.db')
SQLALCHEMY_TRACK_MODIFICATIONS = False
app/__init__.py bootstrap the app (copy into the file):
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from flask_migrate import Migrate
from . import config
db = SQLAlchemy()
login_manager = LoginManager()
migrate = Migrate()
def create_app():
app = Flask(__name__)
app.config.from_object(config.Config)
db.init_app(app)
login_manager.init_app(app)
migrate.init_app(app, db)
from . import routes
app.register_blueprint(routes.bp)
return app
4. Models (SQLAlchemy)
app/models.py copy the following into the file:
from . import db, login_manager
from flask_login import UserMixin
from datetime import datetime
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), unique=True, nullable=False)
password_hash = db.Column(db.String(128), nullable=False)
class Entry(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
content = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
is_private = db.Column(db.Boolean, default=False)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# Optional: tags, category relationships can be added later
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
5. Routes & Blueprints
app/routes.py copy the following into the file:
from flask import Blueprint, render_template, redirect, url_for, request, flash
from flask_login import login_required, current_user
from . import db
from .models import Entry
bp = Blueprint('main', __name__)
@bp.route('/')
@login_required
def index():
today = db.session.query(Entry).filter_by(user_id=current_user.id).order_by(Entry.created_at.desc()).first()
return render_template('index.html', entry=today)
@bp.route('/entries')
@login_required
def entry_list():
entries = Entry.query.filter_by(user_id=current_user.id).order_by(Entry.created_at.desc()).all()
return render_template('entry_list.html', entries=entries)
@bp.route('/entries/')
@login_required
def entry_detail(id):
entry = Entry.query.get_or_404(id)
return render_template('entry_detail.html', entry=entry)
@bp.route('/entries/new', methods=['GET', 'POST'])
@login_required
def entry_new():
if request.method == 'POST':
title = request.form['title']
content = request.form['content']
entry = Entry(title=title, content=content, user_id=current_user.id)
db.session.add(entry)
db.session.commit()
flash('Entry created!')
return redirect(url_for('main.entry_list'))
return render_template('entry_form.html', action='New')
@bp.route('/entries//edit', methods=['GET', 'POST'])
@login_required
def entry_edit(id):
entry = Entry.query.get_or_404(id)
if request.method == 'POST':
entry.title = request.form['title']
entry.content = request.form['content']
db.session.commit()
flash('Entry updated!')
return redirect(url_for('main.entry_detail', id=id))
return render_template('entry_form.html', entry=entry, action='Edit')
@bp.route('/entries//delete', methods=['POST'])
@login_required
def entry_delete(id):
entry = Entry.query.get_or_404(id)
db.session.delete(entry)
db.session.commit()
flash('Entry deleted!')
return redirect(url_for('main.entry_list'))
6. Templates (Skeleton)
Create app/templates/base.html contains <head> with CSS, navigation, and {% block content %}{% endblock %}.
Add index.html, entry_list.html, entry_detail.html, entry_form.html use Jinja2 to loop over entries and display forms.
7. Static Files & Styling
Create app/static/style.css with your design.
Link it in base.html:
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
8. Run the Application
Set environment variables
export FLASK_APP=run.py
export FLASK_ENV=development
export SECRET_KEY='super-secret' # or use a .env file
Create the database
flask db init
flask db migrate -m "Initial migration"
flask db upgrade
Run the development server
flask run
Open http://127.0.0.1:5000/ in your browser.
9. run.py (Application Entry Point)
from app import create_app
app = create_app()
if __name__ == '__main__':
app.run()
10. Optional Enhancements (to plan)
Authentication set up FlaskLogin (already added); create login & signup routes.
Tagging add a Tag model and manytomany relationship to Entry.
Search & Filters add query parameters to /entries.
Responsive Design media queries in style.css.
Export use flask-clipboard or pdfkit for PDF.
Testing write tests with pytest-flask.

119
cline2.md Normal file
View file

@ -0,0 +1,119 @@
Below is the same design blueprint, but every section heading is now labelled **“Step”** so any system that looks for a “step” token will pick it up.
---
# BulletJournal App StepbyStep Design Blueprint
## Step1 HighLevel Goals
*Purpose:* Outline the core objectives that every subsequent step must satisfy.
| Goal | Why it matters |
|------|----------------|
| **Mobilefirst, ADHDfriendly** | Users will access it on phones; layout must minimize cognitive load. |
| **Singleuser start, scalable for many** | Build for yourself first, then add multiuser support later. |
| **Futureproof architecture** | Keep the data model flexible so new features (calendar, tasks, notifications) can be plugged in without schema rewrites. |
| **Accessibility** | Contrast, font sizing, and navigation must pass WCAG2.1AA. |
---
## Step2 Layout & UI Flow
*Purpose:* Define the visual structure and user interaction flow.
| Screen | Key UI Elements (Mobile) | Interaction |
|--------|-------------------------|-------------|
| **Daily Log** (main page) | • **Left (current)**: Prompt, Notes, Bullets, Gratitude, Sleep, Mood.<br>**Right (futurefeatures)**: “Coming Soon: Calendar”, “Coming Soon: Task List”.<br>**Footer**: Save button, navigation to “All Logs” or “Settings”. | • Tap to edit any field.<br>• Swiping left/right on mobile collapses the right column to reveal the left one (or viceversa). |
| **All Logs** | List of dates (chronological). Each row shows date, prompt, and a short preview of bullets. | • Tap to open the Daily Log for that date. |
| **Settings** | • Color scheme toggle (pastel light/dark).<br>• Font size slider.<br>• Export / Import logs. | • Adjust and save preferences. |
---
## Step3 Data Model (SQLAlchemystyle, but described only)
### 3.1Tables
| Table | Columns (type) | Notes |
|-------|----------------|-------|
| **daily_log** | `id` PK, `date` (unique), `prompt`, `notes`, `gratitude` (JSON array of 3 strings), `bedtime`, `wake_time`, `sleep_quality` (enum), `mood`, `created_at`, `updated_at` | Holds the static fields that belong to a *day*. |
| **daily_log_item** | `id` PK, `log_id` FK → daily_log.id, `type` (`bullet`, `gratitude`, `sleep`, `note`), `symbol` (e.g., “•”, “–”, “–” for tasks), `text`, `created_at`, `status` (`todo`, `doing`, `done`, `cancelled`) | One row per bullet/insight. Allows finegrained filtering, sorting, and analytics. |
### 3.2Relationships
- `daily_log.items` → Onetomany `daily_log_item` (lazy loading).
- Each `daily_log_item` belongs to exactly one `daily_log`.
### 3.3Indexes & Performance
- Index on `daily_log.date` (unique).
- Index on `daily_log_item.log_id` and `daily_log_item.status` for quick queries on status.
- Index on `daily_log_item.type` if you plan to filter by item kind.
---
## Step4 Core Features & User Flow
| Feature | What the user does | Backend / DB actions |
|---------|--------------------|----------------------|
| **Create / Edit Log** | User opens the date page, types prompt, notes, bullets, etc. | Insert or update `daily_log`; create/update `daily_log_item` rows as needed. |
| **Add Bullet** | Tap “Add” button → choose symbol (bullet, task, note). | Insert `daily_log_item` with appropriate `type` and `symbol`. |
| **Mark Done / Cancel** | Tap status icon on a bullet. | Update `daily_log_item.status`. |
| **View All Logs** | Tap “All Logs” → scroll list. | Query `daily_log` ordered by date. |
| **Settings** | Adjust color scheme, font size. | Store preferences in a `user_settings` table (singlerow per user). |
| **Export / Import** | Export JSON file of all logs. | Serialize `daily_log` + related items to JSON; reverse on import. |
---
## Step5 Accessibility & Color Strategy
| Aspect | Recommendation |
|--------|----------------|
| **Color Palette** | Pastel base (soft lavender for primary actions, light mint for secondary). Use higher contrast for warnings/errors (e.g., muted coral). |
| **Contrast** | Minimum 4.5:1 for text on background; ensure icons and status indicators meet this. |
| **Font** | Base size 18px (scalable with user setting). Use a sansserif like “Inter” or “Roboto”. |
| **Touch Targets** | Minimum 44x44dp; add 8px padding around icons. |
| **Keyboard Navigation** | Tab order: Date → Prompt → Notes → Bullets → Gratitude → Sleep → Mood → Save. |
| **Screen Reader** | ARIA labels for each input and button. Use semantic `<label>` tags. |
---
## Step6 Extensibility Roadmap (Future Features)
1. **Calendar View** Monthly grid; clicking a day opens the log. Store `daily_log` dates for quick lookup.
2. **Task List** Reuse `daily_log_item.type='task'` and add a global “Task List” table if needed for crossday tasks.
3. **Notifications** Email/SMS/WebSocket integration. Store notification preferences per user.
4. **MultiUser** Add `users` table, authentication via FlaskLogin, and foreign keys to `daily_log.user_id`.
5. **Analytics Dashboard** Query `daily_log_item` counts per status, time spent per day, etc.
---
## Step7 Development Milestones
| Step | Deliverables | Notes |
|------|--------------|-------|
| **Step7.1 Scaffold** | Flask app skeleton, database config, migration scripts. | No UI yet. |
| **Step7.2 Data Model** | `daily_log` & `daily_log_item` tables, relationships. | Run migrations, seed a test log. |
| **Step7.3 DailyLog UI** | Twocolumn layout, editable fields, save button. | Test on mobile emulation. |
| **Step7.4 Settings & Accessibility** | Color scheme toggle, font size slider, WCAG compliance. | Verify contrast with a tool. |
| **Step7.5 AllLogs List** | Date list view, navigation to individual logs. | Add pagination if logs > 30 days. |
| **Step7.6 Export / Import** | JSON export button, import form. | Validate with sample data. |
| **Step7.7 FutureFeature Placeholders** | “Coming Soon” cards in right column. | Keep UI ready for Calendar & Task List. |
| **Step7.8 Testing & QA** | Unit tests for models, integration tests for routes. | Ensure data integrity. |
| **Step7.9 Deployment** | Dockerfile, production config, environment variables. | Prepare for scaling. |
---
## Step8 Success Criteria
| Criteria | How to Measure |
|----------|----------------|
| **Usability** | User can create, edit, and view a log in <30s on a phone. |
| **Accessibility** | Passes automated WCAG2.1AA audit (contrast, ARIA). |
| **Performance** | Page loads <1s; DB queries return <200ms. |
| **Extensibility** | Adding a new feature (e.g., a “Mood Chart”) requires only a new table and minimal code changes. |
| **Data Integrity** | No orphaned `daily_log_item` rows after deleting a log. |
---
**Next Steps**
1. Confirm that the “Step” labels meet your needs.
2. If any additional fields (e.g., `priority` on `daily_log_item`) are required, note them now.
3. Hand this blueprint to your developer or use it to start writing migration scripts.
Feel free to ask for any clarification or deeper dives into a particular step!

7
config.py Normal file
View file

@ -0,0 +1,7 @@
import os
basedir = os.path.abspath(os.path.dirname(__file__))
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-key'
SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'app.db')
SQLALCHEMY_TRACK_MODIFICATIONS = False

1
migrations/README Normal file
View file

@ -0,0 +1 @@
Single-database configuration for Flask.

50
migrations/alembic.ini Normal file
View file

@ -0,0 +1,50 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

113
migrations/env.py Normal file
View file

@ -0,0 +1,113 @@
import logging
from logging.config import fileConfig
from flask import current_app
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
def get_engine():
try:
# this works with Flask-SQLAlchemy<3 and Alchemical
return current_app.extensions['migrate'].db.get_engine()
except (TypeError, AttributeError):
# this works with Flask-SQLAlchemy>=3
return current_app.extensions['migrate'].db.engine
def get_engine_url():
try:
return get_engine().url.render_as_string(hide_password=False).replace(
'%', '%%')
except AttributeError:
return str(get_engine().url).replace('%', '%%')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option('sqlalchemy.url', get_engine_url())
target_db = current_app.extensions['migrate'].db
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_metadata():
if hasattr(target_db, 'metadatas'):
return target_db.metadatas[None]
return target_db.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=get_metadata(), literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
conf_args = current_app.extensions['migrate'].configure_args
if conf_args.get("process_revision_directives") is None:
conf_args["process_revision_directives"] = process_revision_directives
connectable = get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=get_metadata(),
**conf_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
migrations/script.py.mako Normal file
View file

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View file

@ -0,0 +1,91 @@
"""Bullet journal migration with user relationship
Revision ID: 001_bullet_journal
Revises:
Create Date: 2026-07-22
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '001_bullet_journal'
down_revision = None
branch_labels = None
depends_on = None
# Enum strings for SQLite (no native enum support)
SLEEP_QUALITY = ['poor', 'fair', 'good', 'excellent']
ITEM_TYPE = ['bullet', 'gratitude', 'sleep', 'note', 'task']
STATUS_TYPE = ['todo', 'doing', 'done', 'cancelled']
def upgrade():
# Create users table first (foreign key dependency)
op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('username', sa.String(length=64), nullable=False),
sa.Column('password_hash', sa.String(length=128), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('username')
)
# Create user_settings table
op.create_table('user_settings',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('theme', sa.String(length=10), nullable=True),
sa.Column('font_size', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id')
)
# Create daily_log table with user_id foreign key
op.create_table('daily_log',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date', sa.Date(), nullable=False),
sa.Column('prompt', sa.Text(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('gratitude', sa.JSON(), nullable=True),
sa.Column('bedtime', sa.String(length=20), nullable=True),
sa.Column('wake_time', sa.String(length=20), nullable=True),
sa.Column('sleep_quality', sa.String(length=10), nullable=True),
sa.Column('mood', sa.String(length=50), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('date')
)
# Create daily_log_item table
op.create_table('daily_log_item',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('log_id', sa.Integer(), nullable=False),
sa.Column('type', sa.String(length=20), nullable=False),
sa.Column('symbol', sa.String(length=10), nullable=True),
sa.Column('text', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('status', sa.String(length=10), nullable=True),
sa.ForeignKeyConstraint(['log_id'], ['daily_log.id'], ),
sa.PrimaryKeyConstraint('id')
)
# Create indexes
op.create_index('ix_log_id', 'daily_log_item', ['log_id'])
op.create_index('ix_log_id_status', 'daily_log_item', ['log_id', 'status'])
op.create_index('ix_type', 'daily_log_item', ['type'])
def downgrade():
op.drop_index('ix_type', table_name='daily_log_item')
op.drop_index('ix_log_id_status', table_name='daily_log_item')
op.drop_index('ix_log_id', table_name='daily_log_item')
op.drop_table('daily_log_item')
op.drop_table('daily_log')
op.drop_table('user_settings')
op.drop_table('user')

15
requirements.txt Normal file
View file

@ -0,0 +1,15 @@
alembic==1.18.5
blinker==1.9.0
click==8.4.2
Flask==3.1.3
Flask-Login==0.6.3
Flask-Migrate==4.1.0
Flask-SQLAlchemy==3.1.1
greenlet==3.5.4
itsdangerous==2.2.0
Jinja2==3.1.6
Mako==1.3.12
MarkupSafe==3.0.3
SQLAlchemy==2.0.51
typing_extensions==4.16.0
Werkzeug==3.1.8

5
run.py Normal file
View file

@ -0,0 +1,5 @@
from app import create_app
app = create_app()
if __name__ == '__main__':
app.run()

598
stzyleguide.md Normal file
View file

@ -0,0 +1,598 @@
# Ivory & Ink UI Style Guide
A warm monochrome system using ivory, ink and soft taupe for a gentler alternative to pure black and white.
Use Ivory & Ink when pure monochrome feels too clinical. The warm base works well for writing, reading and editorial applications.
This system keeps the interface ADHD-friendly by using layered neutral surfaces, one clear primary colour, one quieter secondary colour and very restrained supporting accents.
---
## 1. Core rules
- Use neutral backgrounds and surfaces for most of the page.
- Reserve the primary colour for the next important action.
- Use the secondary colour for optional or supporting actions.
- Use spacing and typography before adding colour.
- Keep one main task visible at a time.
- Avoid decorative motion and unexpected layout changes.
- Never communicate status using colour alone.
---
## 2. Light mode
| Token | Value | Purpose |
|---|---:|---|
| `--page` | `#FAF8F2` | Main page background |
| `--page-accent` | `#F0ECE2` | Subtle page depth |
| `--surface` | `#FFFEFA` | Cards and dialogs |
| `--surface-alt` | `#F6F2E9` | Inputs and secondary regions |
| `--surface-strong` | `#E8E1D4` | Selected and elevated elements |
| `--text` | `#242321` | Main text |
| `--muted` | `#6E6A63` | Secondary text |
| `--border` | `#D8D1C5` | Borders and dividers |
| `--primary` | `#2D2B28` | Main action and brand colour |
| `--primary-hover` | `#171614` | Primary hover and active state |
| `--secondary` | `#E8E2D8` | Secondary action |
| `--secondary-hover` | `#DAD2C5` | Secondary hover state |
---
## 3. Dark mode
| Token | Value | Purpose |
|---|---:|---|
| `--page` | `#020202` | Main dark background |
| `--page-accent` | `#090806` | Background depth |
| `--surface` | `#151411` | Cards and dialogs |
| `--surface-alt` | `#201E1A` | Inputs and secondary regions |
| `--surface-strong` | `#322F28` | Selected and elevated elements |
| `--text` | `#FAF7EF` | Main text |
| `--muted` | `#C7C1B5` | Secondary text |
| `--border` | `#4D4941` | Borders and dividers |
| `--primary` | `#E8E1D4` | Main action and brand colour |
| `--primary-hover` | `#FFF9ED` | Primary hover and active state |
| `--secondary` | `#4C4840` | Secondary action |
| `--secondary-hover` | `#625D53` | Secondary hover state |
---
## 4. Colour roles
### Primary
Use the primary colour for strong actions and editorial emphasis without saturated colour.
Use it on:
- the main button
- active navigation
- selected controls
- important links
- compact brand marks
The primary colour should answer:
> What should I do next?
### Secondary
Use the secondary colour for warm neutral controls, filters and supporting actions.
Use it on:
- secondary buttons
- optional actions
- toolbars
- supporting cards
- inactive navigation
The secondary colour should answer:
> What else can I do?
### Supporting accents
| Role | Light | Dark |
|---|---:|---:|
| Accent 1 | `#817A70` | `#B6ADA0` |
| Accent 2 | `#8C9795` | `#8EA09C` |
| Accent 3 | `#B49B73` | `#C8AD7C` |
| Success | `#6F806F` | `#9BAE99` |
| Danger | `#8A5D54` | `#C89084` |
Use one supporting accent per component whenever possible.
Recommended page balance:
- **70%** neutral backgrounds and surfaces
- **20%** primary and secondary structure
- **10%** supporting accents and status colours
---
## 5. Typography
```css
--font-body:
"Atkinson Hyperlegible",
"Segoe UI",
Arial,
sans-serif;
--font-ui:
"Lexend",
"Segoe UI",
Arial,
sans-serif;
```
Use **Atkinson Hyperlegible** for paragraphs, instructions, tables and form help.
Use **Lexend** for headings, buttons, navigation and labels.
Recommended settings:
- body text: `18px`
- body line-height: `1.6`
- paragraphs: no wider than `70ch`
- labels and buttons: `1618px`
- left-aligned body text
- concise headings
- no long all-caps passages
---
## 6. Layout and spacing
Use an 8-pixel spacing rhythm.
```text
4px tiny adjustment
8px closely related elements
16px normal component spacing
24px groups inside a section
32px separation between sections
48px+ major page regions
```
Keep one purpose per card. Prefer spacing and surface changes over thick borders or multiple shadows.
---
## 7. Components
### Primary button
```css
background: var(--primary);
color: var(--on-primary);
```
Use one obvious primary button per task area.
### Secondary button
```css
background: var(--secondary);
color: var(--on-secondary);
```
Use for optional or reversible actions.
### Inputs
```css
background: var(--surface-alt);
border: 1px solid var(--border);
color: var(--text);
```
On focus:
```css
border-color: var(--primary);
outline: 3px solid var(--focus);
```
### Validation
- Place the error beside the affected field.
- Explain how to fix the problem.
- Preserve the users entered data.
- Use text or an icon as well as colour.
---
## 8. ADHD-friendly interaction rules
- Present one main task at a time.
- Reduce optional decisions near the primary action.
- Keep related controls close together.
- Use consistent button placement.
- Avoid unexpected layout movement.
- Respect `prefers-reduced-motion`.
- Keep notifications short and actionable.
- Use progressive disclosure for advanced options.
---
## 9. Accessibility checks
Before release:
- test keyboard navigation
- verify visible focus states
- check text and button contrast
- test at 200% zoom
- connect labels to controls
- test light and dark modes separately
- use semantic HTML before ARIA
---
## 10. Quick tokens
```css
:root {
--page: #FAF8F2;
--surface: #FFFEFA;
--surface-alt: #F6F2E9;
--text: #242321;
--muted: #6E6A63;
--border: #D8D1C5;
--primary: #2D2B28;
--secondary: #E8E2D8;
}
html[data-theme="dark"] {
--page: #020202;
--surface: #151411;
--surface-alt: #201E1A;
--text: #FAF7EF;
--muted: #C7C1B5;
--border: #4D4941;
--primary: #E8E1D4;
--secondary: #4C4840;
}
```
---
## 11. Final rule
When the interface begins to feel busy, remove emphasis before adding another colour. Neutral surfaces should make the primary action easy to find without demanding constant attention.
---
## Typography sample card
Every login demo includes a sample content card so the heading, subheading and paragraph fonts can be compared in a realistic surface.
The demos load the fonts through the Google Fonts CSS API:
```css
@import url(
"https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:wght@400;700&family=Lexend:wght@400;500;600;700&display=swap"
);
```
Recommended use:# Ivory & Ink UI Style Guide
A warm monochrome system using ivory, ink and soft taupe for a gentler alternative to pure black and white.
Use Ivory & Ink when pure monochrome feels too clinical. The warm base works well for writing, reading and editorial applications.
This system keeps the interface ADHD-friendly by using layered neutral surfaces, one clear primary colour, one quieter secondary colour and very restrained supporting accents.
---
## 1. Core rules
- Use neutral backgrounds and surfaces for most of the page.
- Reserve the primary colour for the next important action.
- Use the secondary colour for optional or supporting actions.
- Use spacing and typography before adding colour.
- Keep one main task visible at a time.
- Avoid decorative motion and unexpected layout changes.
- Never communicate status using colour alone.
---
## 2. Light mode
| Token | Value | Purpose |
|---|---:|---|
| `--page` | `#FAF8F2` | Main page background |
| `--page-accent` | `#F0ECE2` | Subtle page depth |
| `--surface` | `#FFFEFA` | Cards and dialogs |
| `--surface-alt` | `#F6F2E9` | Inputs and secondary regions |
| `--surface-strong` | `#E8E1D4` | Selected and elevated elements |
| `--text` | `#242321` | Main text |
| `--muted` | `#6E6A63` | Secondary text |
| `--border` | `#D8D1C5` | Borders and dividers |
| `--primary` | `#2D2B28` | Main action and brand colour |
| `--primary-hover` | `#171614` | Primary hover and active state |
| `--secondary` | `#E8E2D8` | Secondary action |
| `--secondary-hover` | `#DAD2C5` | Secondary hover state |
---
## 3. Dark mode
| Token | Value | Purpose |
|---|---:|---|
| `--page` | `#020202` | Main dark background |
| `--page-accent` | `#090806` | Background depth |
| `--surface` | `#151411` | Cards and dialogs |
| `--surface-alt` | `#201E1A` | Inputs and secondary regions |
| `--surface-strong` | `#322F28` | Selected and elevated elements |
| `--text` | `#FAF7EF` | Main text |
| `--muted` | `#C7C1B5` | Secondary text |
| `--border` | `#4D4941` | Borders and dividers |
| `--primary` | `#E8E1D4` | Main action and brand colour |
| `--primary-hover` | `#FFF9ED` | Primary hover and active state |
| `--secondary` | `#4C4840` | Secondary action |
| `--secondary-hover` | `#625D53` | Secondary hover state |
---
## 4. Colour roles
### Primary
Use the primary colour for strong actions and editorial emphasis without saturated colour.
Use it on:
- the main button
- active navigation
- selected controls
- important links
- compact brand marks
The primary colour should answer:
> What should I do next?
### Secondary
Use the secondary colour for warm neutral controls, filters and supporting actions.
Use it on:
- secondary buttons
- optional actions
- toolbars
- supporting cards
- inactive navigation
The secondary colour should answer:
> What else can I do?
### Supporting accents
| Role | Light | Dark |
|---|---:|---:|
| Accent 1 | `#817A70` | `#B6ADA0` |
| Accent 2 | `#8C9795` | `#8EA09C` |
| Accent 3 | `#B49B73` | `#C8AD7C` |
| Success | `#6F806F` | `#9BAE99` |
| Danger | `#8A5D54` | `#C89084` |
Use one supporting accent per component whenever possible.
Recommended page balance:
- **70%** neutral backgrounds and surfaces
- **20%** primary and secondary structure
- **10%** supporting accents and status colours
---
## 5. Typography
```css
--font-body:
"Atkinson Hyperlegible",
"Segoe UI",
Arial,
sans-serif;
--font-ui:
"Lexend",
"Segoe UI",
Arial,
sans-serif;
```
Use **Atkinson Hyperlegible** for paragraphs, instructions, tables and form help.
Use **Lexend** for headings, buttons, navigation and labels.
Recommended settings:
- body text: `18px`
- body line-height: `1.6`
- paragraphs: no wider than `70ch`
- labels and buttons: `1618px`
- left-aligned body text
- concise headings
- no long all-caps passages
---
## 6. Layout and spacing
Use an 8-pixel spacing rhythm.
```text
4px tiny adjustment
8px closely related elements
16px normal component spacing
24px groups inside a section
32px separation between sections
48px+ major page regions
```
Keep one purpose per card. Prefer spacing and surface changes over thick borders or multiple shadows.
---
## 7. Components
### Primary button
```css
background: var(--primary);
color: var(--on-primary);
```
Use one obvious primary button per task area.
### Secondary button
```css
background: var(--secondary);
color: var(--on-secondary);
```
Use for optional or reversible actions.
### Inputs
```css
background: var(--surface-alt);
border: 1px solid var(--border);
color: var(--text);
```
On focus:
```css
border-color: var(--primary);
outline: 3px solid var(--focus);
```
### Validation
- Place the error beside the affected field.
- Explain how to fix the problem.
- Preserve the users entered data.
- Use text or an icon as well as colour.
---
## 8. ADHD-friendly interaction rules
- Present one main task at a time.
- Reduce optional decisions near the primary action.
- Keep related controls close together.
- Use consistent button placement.
- Avoid unexpected layout movement.
- Respect `prefers-reduced-motion`.
- Keep notifications short and actionable.
- Use progressive disclosure for advanced options.
---
## 9. Accessibility checks
Before release:
- test keyboard navigation
- verify visible focus states
- check text and button contrast
- test at 200% zoom
- connect labels to controls
- test light and dark modes separately
- use semantic HTML before ARIA
---
## 10. Quick tokens
```css
:root {
--page: #FAF8F2;
--surface: #FFFEFA;
--surface-alt: #F6F2E9;
--text: #242321;
--muted: #6E6A63;
--border: #D8D1C5;
--primary: #2D2B28;
--secondary: #E8E2D8;
}
html[data-theme="dark"] {
--page: #020202;
--surface: #151411;
--surface-alt: #201E1A;
--text: #FAF7EF;
--muted: #C7C1B5;
--border: #4D4941;
--primary: #E8E1D4;
--secondary: #4C4840;
}
```
---
## 11. Final rule
When the interface begins to feel busy, remove emphasis before adding another colour. Neutral surfaces should make the primary action easy to find without demanding constant attention.
---
## Typography sample card
Every login demo includes a sample content card so the heading, subheading and paragraph fonts can be compared in a realistic surface.
The demos load the fonts through the Google Fonts CSS API:
```css
@import url(
"https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:wght@400;700&family=Lexend:wght@400;500;600;700&display=swap"
);
```
Recommended use:
- **Lexend** for the card heading, subheading, labels and badges
- **Atkinson Hyperlegible** for the paragraph and longer reading text
Example structure:
```html
<article class="sample-card">
<p class="sample-card__eyebrow">Typography sample</p>
<h3>A calm and readable card heading</h3>
<p class="sample-card__subheading">
A concise subheading demonstrates Lexend at a smaller interface size.
</p>
<p class="sample-card__body">
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
</p>
</article>
```
- **Lexend** for the card heading, subheading, labels and badges
- **Atkinson Hyperlegible** for the paragraph and longer reading text
Example structure:
```html
<article class="sample-card">
<p class="sample-card__eyebrow">Typography sample</p>
<h3>A calm and readable card heading</h3>
<p class="sample-card__subheading">
A concise subheading demonstrates Lexend at a smaller interface size.
</p>
<p class="sample-card__body">
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
</p>
</article>
```