-
Notifications
You must be signed in to change notification settings - Fork 0
feat(ui): implement color theme design system (issue #13) #90
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| { | ||
| "permissions": { | ||
| "allow": [ | ||
| "Bash(gh auth:*)", | ||
| "Bash(git remote:*)", | ||
| "Bash(gh issue:*)", | ||
| "mcp__zenable__conformance_check", | ||
| "Bash(git -C c:/Users/student/code/todo-app checkout -b feature/issue-13-color-theme)", | ||
| "Bash(git add:*)", | ||
| "Bash(git commit -m ':*)", | ||
| "Bash(git config:*)", | ||
| "Bash(git commit -m 'feat\\(ui\\): implement color theme and security hardening \\(#13\\):*)", | ||
| "Bash(docker info:*)", | ||
| "Bash(.venv/Scripts/python.exe -mpre_commit run --all-files)" | ||
| ] | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| # Spec: Color Theme (Issue #13) | ||
|
|
||
| ## Overview | ||
|
|
||
| Implement a professional color palette and consistent typography across the todo-app UI to establish a cohesive visual identity and improve usability. | ||
|
|
||
| ## Background | ||
|
|
||
| **Issue**: [#13 Color Theme](https://github.com/labworksdev/todo-app/issues/13) | ||
| **Labels**: `tier-1`, `ui-ux`, `sprint-1`, `demo9` | ||
| **Assignee**: demo9-labworksdev | ||
|
|
||
| The application currently lacks a defined visual design system. This spec establishes the foundational design tokens — colors, typography, and component styles — that will be applied consistently across the UI. | ||
|
|
||
| --- | ||
|
|
||
| ## Goals | ||
|
|
||
| - Define a cohesive color palette using CSS custom properties (design tokens) | ||
| - Establish consistent heading and body font styles | ||
| - Style all interactive elements: buttons, inputs, and links | ||
| - Create clear visual hierarchy to guide user attention | ||
|
|
||
| ## Non-Goals | ||
|
|
||
| - Dark mode support (can be a follow-up) | ||
| - Animations or transitions (separate concern) | ||
| - Responsive/mobile-specific layout changes | ||
|
|
||
| --- | ||
|
|
||
| ## Design Tokens | ||
|
|
||
| ### Color Palette | ||
|
|
||
| | Token | Value | Usage | | ||
| |-------|-------|-------| | ||
| | `--color-primary` | `#2563EB` | Primary actions, links | | ||
| | `--color-primary-hover` | `#1D4ED8` | Hover state for primary | | ||
| | `--color-secondary` | `#64748B` | Secondary text, borders | | ||
| | `--color-success` | `#16A34A` | Completed todos, confirmations | | ||
| | `--color-danger` | `#DC2626` | Delete actions, errors | | ||
| | `--color-warning` | `#D97706` | Warnings, pending states | | ||
| | `--color-bg` | `#F8FAFC` | Page background | | ||
| | `--color-surface` | `#FFFFFF` | Card/panel backgrounds | | ||
| | `--color-border` | `#E2E8F0` | Borders, dividers | | ||
| | `--color-text` | `#1E293B` | Primary body text | | ||
| | `--color-text-muted` | `#94A3B8` | Placeholder, disabled text | | ||
|
|
||
| ### Typography | ||
|
|
||
| | Token | Value | Usage | | ||
| |-------|-------|-------| | ||
| | `--font-family` | `Inter, system-ui, sans-serif` | All text | | ||
| | `--font-size-sm` | `0.875rem` (14px) | Labels, captions | | ||
| | `--font-size-base` | `1rem` (16px) | Body text | | ||
| | `--font-size-lg` | `1.125rem` (18px) | Subheadings | | ||
| | `--font-size-xl` | `1.25rem` (20px) | Section headings | | ||
| | `--font-size-2xl` | `1.5rem` (24px) | Page title | | ||
| | `--font-weight-normal` | `400` | Body text | | ||
| | `--font-weight-medium` | `500` | Labels, nav items | | ||
| | `--font-weight-bold` | `700` | Headings | | ||
| | `--line-height-body` | `1.6` | Body text | | ||
| | `--line-height-heading` | `1.2` | Headings | | ||
|
|
||
| --- | ||
|
|
||
| ## Component Styles | ||
|
|
||
| ### Buttons | ||
|
|
||
| | Variant | Background | Text | Border | Use Case | | ||
| |---------|------------|------|--------|----------| | ||
| | Primary | `--color-primary` | white | none | Add todo, Save | | ||
| | Secondary | transparent | `--color-secondary` | `--color-border` | Cancel, Back | | ||
| | Danger | `--color-danger` | white | none | Delete todo | | ||
|
|
||
| All buttons: `border-radius: 6px`, `padding: 0.5rem 1rem`, `font-weight: medium`. | ||
|
|
||
| ### Inputs | ||
|
|
||
| - Border: `1px solid --color-border` | ||
| - Border radius: `6px` | ||
| - Padding: `0.5rem 0.75rem` | ||
| - Focus ring: `2px solid --color-primary` with `outline-offset: 2px` | ||
| - Placeholder text color: `--color-text-muted` | ||
|
|
||
| ### Links | ||
|
|
||
| - Default: `--color-primary`, no underline | ||
| - Hover: `--color-primary-hover`, underline | ||
| - Visited: `--color-secondary` | ||
|
|
||
| ### Visual Hierarchy | ||
|
|
||
| 1. **Page title** — `2xl`, `bold`, `--color-text` | ||
| 2. **Section headings** — `xl`, `bold`, `--color-text` | ||
| 3. **Item titles** — `base`, `medium`, `--color-text` | ||
| 4. **Supporting text** — `sm`, `normal`, `--color-text-muted` | ||
|
|
||
| --- | ||
|
|
||
| ## Implementation Plan | ||
|
|
||
| 1. **Create `static/css/tokens.css`** — define all CSS custom properties under `:root` | ||
| 2. **Create `static/css/base.css`** — apply tokens to HTML elements (`body`, `h1`–`h4`, `a`, `input`, `button`) | ||
| 3. **Create `static/css/components.css`** — button variants, input states, links | ||
| 4. **Import stylesheets** in the base HTML template | ||
| 5. **Audit existing templates** — replace any hardcoded colors/fonts with token references | ||
|
|
||
| ## Acceptance Criteria | ||
|
|
||
| - [ ] All color and typography values sourced from CSS custom properties | ||
| - [ ] Page title, section headings, body text, and muted text are visually distinct | ||
| - [ ] Primary, secondary, and danger button variants are implemented and used consistently | ||
| - [ ] Input fields have visible focus states meeting <!-- cspell: ignore WCAG -->WCAG 2.1 AA contrast requirements | ||
| - [ ] Links are distinguishable from body text without relying solely on color | ||
| - [ ] No hardcoded hex values or font names outside of `tokens.css` | ||
|
|
||
| --- | ||
|
|
||
| ## References | ||
|
|
||
| - [ARCHITECTURE.md](../ARCHITECTURE.md) — project structure | ||
| - [<!-- cspell: ignore WCAG -->WCAG 2.1 AA contrast guidelines](https://www.w3.org/TR/WCAG21/#contrast-minimum) |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,178 @@ | ||||||||||
| """Generate a PBKDF2-SHA256 password hash for use as ADMIN_PASSWORD_HASH. | ||||||||||
|
|
||||||||||
| Usage: | ||||||||||
| SETUP_SECRET=<value> python scripts/generate_password_hash.py | ||||||||||
|
|
||||||||||
| The SETUP_SECRET environment variable must match the value set at deploy time | ||||||||||
| to prevent unauthorized execution of this script. | ||||||||||
|
|
||||||||||
| Required environment variables: | ||||||||||
| SETUP_SECRET Primary authorization secret. | ||||||||||
| SETUP_TOKEN Secondary authorization token — acts as a second factor so | ||||||||||
| knowledge of SETUP_SECRET alone is insufficient to run the script. | ||||||||||
|
|
||||||||||
| Optional environment variables: | ||||||||||
| PBKDF2_ITERATIONS Number of PBKDF2 iterations (default: 600000). | ||||||||||
| The value is embedded in the hash output so | ||||||||||
| verification is self-contained — increase it over | ||||||||||
| time without breaking existing hashes. | ||||||||||
| PBKDF2_MIN_ITERATIONS Minimum acceptable iteration count (default: 200000). | ||||||||||
| Raise this over time as hardware improves. | ||||||||||
| """ | ||||||||||
|
|
||||||||||
| import getpass | ||||||||||
| import hashlib | ||||||||||
| import hmac | ||||||||||
| import json | ||||||||||
| import os | ||||||||||
| import secrets | ||||||||||
| import sys | ||||||||||
| import time | ||||||||||
| from pathlib import Path | ||||||||||
|
|
||||||||||
| _DEFAULT_ITERATIONS = 600_000 | ||||||||||
| _FALLBACK_MIN_ITERATIONS = 200_000 | ||||||||||
| _MIN_PASSWORD_LENGTH = 12 | ||||||||||
| _MIN_SECRET_LENGTH = 32 # Minimum length for SETUP_SECRET and SETUP_TOKEN | ||||||||||
| _MAX_ATTEMPTS = 5 | ||||||||||
| _LOCKOUT_SECONDS = 900 # 15 minutes | ||||||||||
| _ATTEMPT_DELAY_SECONDS = 2 # Slow brute-force after each failure | ||||||||||
| _ATTEMPT_FILE = Path.home() / ".todo-app-setup-attempts" | ||||||||||
|
|
||||||||||
|
|
||||||||||
| def _check_rate_limit() -> None: | ||||||||||
| """Enforce a lockout after repeated failed credential attempts. Exits on violation.""" | ||||||||||
| try: | ||||||||||
| if _ATTEMPT_FILE.exists(): | ||||||||||
| data = json.loads(_ATTEMPT_FILE.read_text()) | ||||||||||
| failures = data.get("failures", 0) | ||||||||||
| locked_until = data.get("locked_until", 0) | ||||||||||
| if failures >= _MAX_ATTEMPTS and time.time() < locked_until: | ||||||||||
| remaining = int(locked_until - time.time()) | ||||||||||
| print(f"Error: too many failed attempts. Try again in {remaining}s.", file=sys.stderr) | ||||||||||
| sys.exit(1) | ||||||||||
| except (OSError, json.JSONDecodeError, KeyError): | ||||||||||
| pass # Corrupt or missing file — allow the attempt. | ||||||||||
|
|
||||||||||
|
|
||||||||||
| def _record_failure() -> None: | ||||||||||
| """Increment the on-disk failure counter, set a lockout timestamp, and delay to slow brute-force.""" | ||||||||||
| try: | ||||||||||
| data: dict = {} | ||||||||||
| if _ATTEMPT_FILE.exists(): | ||||||||||
| data = json.loads(_ATTEMPT_FILE.read_text()) | ||||||||||
| failures = data.get("failures", 0) + 1 | ||||||||||
| locked_until = time.time() + _LOCKOUT_SECONDS if failures >= _MAX_ATTEMPTS else 0 | ||||||||||
| _ATTEMPT_FILE.write_text(json.dumps({"failures": failures, "locked_until": locked_until})) | ||||||||||
| except OSError: | ||||||||||
| pass | ||||||||||
| # Always delay after a failure to slow down automated attacks. | ||||||||||
| time.sleep(_ATTEMPT_DELAY_SECONDS) | ||||||||||
|
|
||||||||||
|
|
||||||||||
| def _clear_failures() -> None: | ||||||||||
| """Remove the failure record after a successful authentication.""" | ||||||||||
| try: | ||||||||||
| _ATTEMPT_FILE.unlink(missing_ok=True) | ||||||||||
| except OSError: | ||||||||||
| pass | ||||||||||
|
|
||||||||||
|
|
||||||||||
| def _check_password_strength(password: str) -> list[str]: | ||||||||||
| """Return a list of unmet password requirements; empty list means the password passes.""" | ||||||||||
| errors = [] | ||||||||||
| if len(password) < _MIN_PASSWORD_LENGTH: | ||||||||||
| errors.append(f"at least {_MIN_PASSWORD_LENGTH} characters") | ||||||||||
| if not any(c.isupper() for c in password): | ||||||||||
| errors.append("at least one uppercase letter") | ||||||||||
| if not any(c.islower() for c in password): | ||||||||||
| errors.append("at least one lowercase letter") | ||||||||||
| if not any(c.isdigit() for c in password): | ||||||||||
| errors.append("at least one digit") | ||||||||||
| if not any(not c.isalnum() for c in password): | ||||||||||
| errors.append("at least one special character (e.g. !@#$%)") | ||||||||||
| return errors | ||||||||||
|
|
||||||||||
|
|
||||||||||
| def main() -> None: | ||||||||||
| """Verify the setup secret, then prompt for a password and print its hash.""" | ||||||||||
| _check_rate_limit() | ||||||||||
|
|
||||||||||
| expected = os.environ.get("SETUP_SECRET") | ||||||||||
| if not expected: | ||||||||||
| print( | ||||||||||
| "Error: SETUP_SECRET environment variable is not set.", | ||||||||||
| file=sys.stderr, | ||||||||||
| ) | ||||||||||
| sys.exit(1) | ||||||||||
| if len(expected) < _MIN_SECRET_LENGTH: | ||||||||||
| print( | ||||||||||
| f"Error: SETUP_SECRET must be at least {_MIN_SECRET_LENGTH} characters.", | ||||||||||
Check failureCode scanning / CodeQL Clear-text logging of sensitive information High
This expression logs
sensitive data (secret) Error loading related location Loading |
||||||||||
|
|
||||||||||
| file=sys.stderr, | ||||||||||
| ) | ||||||||||
| sys.exit(1) | ||||||||||
|
|
||||||||||
| expected_token = os.environ.get("SETUP_TOKEN") | ||||||||||
| if not expected_token: | ||||||||||
| print("Error: SETUP_TOKEN environment variable is not set.", file=sys.stderr) | ||||||||||
| sys.exit(1) | ||||||||||
| if len(expected_token) < _MIN_SECRET_LENGTH: | ||||||||||
| print( | ||||||||||
| f"Error: SETUP_TOKEN must be at least {_MIN_SECRET_LENGTH} characters.", | ||||||||||
Check failureCode scanning / CodeQL Clear-text logging of sensitive information High
This expression logs
sensitive data (secret) Error loading related location Loading |
||||||||||
|
|
||||||||||
| file=sys.stderr, | ||||||||||
| ) | ||||||||||
| sys.exit(1) | ||||||||||
|
|
||||||||||
| provided = getpass.getpass("Setup secret: ") | ||||||||||
| provided_token = getpass.getpass("Setup token: ") | ||||||||||
| # Both comparisons always run (results stored before the conditional) so | ||||||||||
| # neither leaks which credential was wrong via a short-circuit timing difference. | ||||||||||
| secret_ok = hmac.compare_digest(provided.encode(), expected.encode()) | ||||||||||
| token_ok = hmac.compare_digest(provided_token.encode(), expected_token.encode()) | ||||||||||
| if not (secret_ok and token_ok): | ||||||||||
| _record_failure() | ||||||||||
| print("Error: incorrect setup credentials.", file=sys.stderr) | ||||||||||
| sys.exit(1) | ||||||||||
|
|
||||||||||
| _clear_failures() | ||||||||||
|
|
||||||||||
| try: | ||||||||||
| min_iterations = int(os.environ.get("PBKDF2_MIN_ITERATIONS", str(_FALLBACK_MIN_ITERATIONS))) | ||||||||||
| if min_iterations < 1: | ||||||||||
| raise ValueError | ||||||||||
| except ValueError: | ||||||||||
| print("Error: PBKDF2_MIN_ITERATIONS must be a positive integer.", file=sys.stderr) | ||||||||||
| sys.exit(1) | ||||||||||
|
|
||||||||||
| iterations_env = os.environ.get("PBKDF2_ITERATIONS", str(_DEFAULT_ITERATIONS)) | ||||||||||
| try: | ||||||||||
| iterations = int(iterations_env) | ||||||||||
| if iterations < min_iterations: | ||||||||||
| raise ValueError | ||||||||||
| except ValueError: | ||||||||||
| print( | ||||||||||
| f"Error: PBKDF2_ITERATIONS must be an integer >= {min_iterations}.", | ||||||||||
| file=sys.stderr, | ||||||||||
| ) | ||||||||||
| sys.exit(1) | ||||||||||
|
|
||||||||||
| password = getpass.getpass("New password: ") | ||||||||||
| strength_errors = _check_password_strength(password) | ||||||||||
| if strength_errors: | ||||||||||
| print("Error: password does not meet requirements:", file=sys.stderr) | ||||||||||
| for err in strength_errors: | ||||||||||
| print(f" - {err}", file=sys.stderr) | ||||||||||
Check failureCode scanning / CodeQL Clear-text logging of sensitive information High
This expression logs
sensitive data (password) Error loading related location Loading This expression logs sensitive data (password) Error loading related location Loading |
||||||||||
|
|
||||||||||
| sys.exit(1) | ||||||||||
|
|
||||||||||
| confirm = getpass.getpass("Confirm password: ") | ||||||||||
| if password != confirm: | ||||||||||
| print("Error: passwords do not match.", file=sys.stderr) | ||||||||||
| sys.exit(1) | ||||||||||
|
Comment on lines
+168
to
+170
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The password confirmation comparison uses
Suggested change
Why did I show this?Category: security Based on general best practices Tools used:
|
||||||||||
|
|
||||||||||
| salt = os.urandom(16) | ||||||||||
| key = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iterations) | ||||||||||
| print(f"{iterations}:{salt.hex()}:{key.hex()}") | ||||||||||
|
|
||||||||||
|
|
||||||||||
| if __name__ == "__main__": | ||||||||||
| main() | ||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This file contains an absolute local filesystem path (
c:/Users/student/code/todo-app) which is developer-machine-specific and should not be committed to the repository. This leaks the local environment structure and will break for any other contributor. The.claude/settings.local.jsonfile should be added to.gitignorerather than committed.Why did I show this?
Category: security
Comment Quality: high
Influenced by requirements:
Tools used:
list_changed_files,{'pattern': {'type': 'string', 'value': '**/*.css'}}list_changed_files,{'pattern': '**/*.css'}get_file_lines,{'file_path': 'src/static/css/tokens.css', 'start_line': 1, 'end_line': 200}get_file_lines,{'file_path': 'src/static/css/base.css', 'start_line': 1, 'end_line': 200}get_file_lines,{'file_path': 'src/static/css/components.css', 'start_line': 1, 'end_line': 200}get_file_lines,{'file_path': 'src/templates/index.html', 'start_line': 1, 'end_line': 200}get_file_lines,{'file_path': 'src/templates/login.html', 'start_line': 1, 'end_line': 200}