fresco 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/exe/fresco +3 -0
  3. data/lib/fresco/application.rb +12 -0
  4. data/lib/fresco/cli/build.rb +682 -0
  5. data/lib/fresco/cli/dev.rb +17 -0
  6. data/lib/fresco/cli/dev_loop.rb +815 -0
  7. data/lib/fresco/cli/new.rb +120 -0
  8. data/lib/fresco/cli/release.rb +76 -0
  9. data/lib/fresco/cli.rb +56 -0
  10. data/lib/fresco/database_config.rb +34 -0
  11. data/lib/fresco/generators/app/Gemfile.tt +18 -0
  12. data/lib/fresco/generators/app/README.md.tt +32 -0
  13. data/lib/fresco/generators/app/app/action.rb.tt +20 -0
  14. data/lib/fresco/generators/app/app/actions/root_path.rb.tt +5 -0
  15. data/lib/fresco/generators/app/app/views/layouts/application.html.erb +29 -0
  16. data/lib/fresco/generators/app/app/views/root_path.html.erb +8 -0
  17. data/lib/fresco/generators/app/app.rb.tt +15 -0
  18. data/lib/fresco/generators/app/bin/build +2 -0
  19. data/lib/fresco/generators/app/bin/dev +2 -0
  20. data/lib/fresco/generators/app/bin/release +2 -0
  21. data/lib/fresco/generators/app/config/app.rb.tt +26 -0
  22. data/lib/fresco/generators/app/config/database.rb +17 -0
  23. data/lib/fresco/generators/app/config/routes.rb +11 -0
  24. data/lib/fresco/generators/app/db/schema.rb +14 -0
  25. data/lib/fresco/generators/app/public/404.html +87 -0
  26. data/lib/fresco/generators/app/public/500.html +84 -0
  27. data/lib/fresco/migration_builder.rb +55 -0
  28. data/lib/fresco/model_builder.rb +54 -0
  29. data/lib/fresco/paths.rb +20 -0
  30. data/lib/fresco/router.rb +67 -0
  31. data/lib/fresco/runtime/boot.rb +34 -0
  32. data/lib/fresco/runtime/db_postgres.rb +403 -0
  33. data/lib/fresco/runtime/db_sqlite.rb +495 -0
  34. data/lib/fresco/runtime/http.c +456 -0
  35. data/lib/fresco/runtime/postgres.c +339 -0
  36. data/lib/fresco/runtime/runtime.rb +1810 -0
  37. data/lib/fresco/runtime/sqlite.c +220 -0
  38. data/lib/fresco/runtime/welcome.rb +152 -0
  39. data/lib/fresco/schema_builder.rb +71 -0
  40. data/lib/fresco/templates/dispatch.rb.erb +32 -0
  41. data/lib/fresco/templates/layout_dispatch.rb.erb +16 -0
  42. data/lib/fresco/templates/manifest.rb.erb +5 -0
  43. data/lib/fresco/templates/migrations.rb.erb +152 -0
  44. data/lib/fresco/templates/model.rb.erb +223 -0
  45. data/lib/fresco/templates/view.rb.erb +5 -0
  46. data/lib/fresco/version.rb +3 -0
  47. data/lib/fresco.rb +61 -0
  48. metadata +115 -0
@@ -0,0 +1,120 @@
1
+ require "erb"
2
+ require "fileutils"
3
+
4
+ require "fresco/paths"
5
+
6
+ module Fresco
7
+ class CLI
8
+ class New
9
+ USAGE = <<~USAGE
10
+ Usage: fresco new <app_name> [--fresco-path <dir>]
11
+
12
+ --fresco-path <dir> Generate a Gemfile pinned to a path-installed
13
+ fresco (instead of the rubygems version). Useful
14
+ during co-development of fresco itself.
15
+
16
+ Example:
17
+ fresco new bookshelf
18
+ fresco new bookshelf --fresco-path ../fresco
19
+ USAGE
20
+
21
+ def run(argv)
22
+ app_name = nil
23
+ fresco_path = nil
24
+ i = 0
25
+ while i < argv.length
26
+ a = argv[i]
27
+ if a == "--fresco-path"
28
+ fresco_path = argv[i + 1]
29
+ unless fresco_path
30
+ warn "fresco: --fresco-path requires a directory argument"
31
+ return 1
32
+ end
33
+ i += 2
34
+ elsif a.start_with?("-")
35
+ warn "fresco: unknown flag #{a.inspect}"
36
+ warn USAGE
37
+ return 1
38
+ else
39
+ if app_name
40
+ warn "fresco: positional <app_name> already set to #{app_name.inspect}"
41
+ return 1
42
+ end
43
+ app_name = a
44
+ i += 1
45
+ end
46
+ end
47
+
48
+ if app_name.nil? || app_name.empty?
49
+ warn USAGE
50
+ return 1
51
+ end
52
+
53
+ unless app_name.match?(/\A[a-z][a-z0-9_]*\z/)
54
+ warn "fresco: app name #{app_name.inspect} must be lowercase letters/digits/underscores starting with a letter"
55
+ return 1
56
+ end
57
+
58
+ target = File.expand_path(app_name)
59
+ if File.exist?(target)
60
+ warn "fresco: #{target} already exists"
61
+ return 1
62
+ end
63
+
64
+ locals = {
65
+ app_name: app_name,
66
+ module_name: camelize(app_name),
67
+ fresco_source: fresco_path ? ", path: #{File.expand_path(fresco_path).inspect}" : "",
68
+ }
69
+
70
+ FileUtils.mkdir_p(target)
71
+ scaffold_root = Fresco::Paths.generator_root
72
+
73
+ copied = 0
74
+ Dir.glob("#{scaffold_root}/**/*", File::FNM_DOTMATCH).sort.each do |src|
75
+ next if src.end_with?("/.", "/..")
76
+ rel = src.sub(/\A#{Regexp.escape(scaffold_root)}\/?/, "")
77
+ next if rel.empty?
78
+ dst = File.join(target, strip_template_suffix(rel))
79
+
80
+ if File.directory?(src)
81
+ FileUtils.mkdir_p(dst)
82
+ next
83
+ end
84
+
85
+ FileUtils.mkdir_p(File.dirname(dst))
86
+ if src.end_with?(".tt")
87
+ File.write(dst, ERB.new(File.read(src), trim_mode: "-").result_with_hash(locals))
88
+ else
89
+ FileUtils.cp(src, dst)
90
+ end
91
+
92
+ # Preserve executable bit for bin/* scripts (cp doesn't strip it,
93
+ # but ERB-rendered .tt files re-create with default 0644). The
94
+ # generator tree doesn't currently put .tt files under bin/, but
95
+ # chmod here makes the rule uniform.
96
+ File.chmod(0o755, dst) if rel.start_with?("bin/")
97
+ copied += 1
98
+ end
99
+
100
+ puts "Created #{app_name}/ (#{copied} files)"
101
+ puts ""
102
+ puts " cd #{app_name}"
103
+ puts " bundle install"
104
+ puts " bin/dev"
105
+ puts ""
106
+ 0
107
+ end
108
+
109
+ private
110
+
111
+ def camelize(name)
112
+ name.split("_").map(&:capitalize).join
113
+ end
114
+
115
+ def strip_template_suffix(rel)
116
+ rel.end_with?(".tt") ? rel[0...-3] : rel
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,76 @@
1
+ require "fileutils"
2
+
3
+ module Fresco
4
+ class CLI
5
+ class Release
6
+ # On macOS, libpq's headers ship in Homebrew's keg-only `libpq`
7
+ # formula (or Postgres.app), so they're not in cc's default
8
+ # include path. Auto-detect a few known locations and prepend
9
+ # them to CPATH / LIBRARY_PATH so Spinel's compile of the
10
+ # postgres.c shim finds libpq-fe.h / -lpq without the user
11
+ # having to remember the export dance. SQLite builds are
12
+ # unaffected — cc ignores include/lib paths that nothing
13
+ # references.
14
+ LIBPQ_ROOTS = [
15
+ "/opt/homebrew/opt/libpq",
16
+ "/usr/local/opt/libpq",
17
+ "/Applications/Postgres.app/Contents/Versions/latest",
18
+ ].freeze
19
+
20
+ def run(argv = [])
21
+ # Regenerate generated/* first so the binary picks up any
22
+ # routes/views/actions that changed since the last dev cycle.
23
+ require "fresco/cli/build"
24
+ Fresco::CLI::Build.new.run
25
+
26
+ export_libpq_env!
27
+
28
+ spinel = find_spinel_binary
29
+ unless spinel
30
+ abort <<~MSG
31
+ [release] no spinel binary found. Tried: ./spinel, vendor/spinel/bin/spinel, $PATH.
32
+ Build Spinel from https://github.com/.../spinel and place the binary at
33
+ ./spinel, vendor/spinel/bin/spinel, or on your PATH.
34
+ MSG
35
+ end
36
+
37
+ FileUtils.mkdir_p("build")
38
+ unless system(spinel, "app.rb", "-o", "build/app")
39
+ abort "[release] spinel failed; see output above"
40
+ end
41
+
42
+ size = File.size("build/app")
43
+ puts "built build/app (#{size} bytes)"
44
+ 0
45
+ end
46
+
47
+ private
48
+
49
+ def export_libpq_env!
50
+ LIBPQ_ROOTS.each do |root|
51
+ header = File.join(root, "include/libpq-fe.h")
52
+ next unless File.exist?(header)
53
+ prepend_env("CPATH", File.join(root, "include"))
54
+ prepend_env("LIBRARY_PATH", File.join(root, "lib"))
55
+ puts "[release] using libpq at #{root}"
56
+ break
57
+ end
58
+ end
59
+
60
+ def prepend_env(var, dir)
61
+ existing = ENV[var]
62
+ ENV[var] = existing && !existing.empty? ? "#{dir}:#{existing}" : dir
63
+ end
64
+
65
+ # Spinel binary lookup ladder. Order matches the messages the
66
+ # release script prints on miss; keep them in sync if you change
67
+ # one.
68
+ def find_spinel_binary
69
+ return File.expand_path("./spinel") if File.executable?("./spinel")
70
+ return File.expand_path("vendor/spinel/bin/spinel") if File.executable?("vendor/spinel/bin/spinel")
71
+ path = `command -v spinel 2>/dev/null`.strip
72
+ path.empty? ? nil : path
73
+ end
74
+ end
75
+ end
76
+ end
data/lib/fresco/cli.rb ADDED
@@ -0,0 +1,56 @@
1
+ require "fresco/version"
2
+
3
+ # Subcommand dispatcher. Each command lives in lib/fresco/cli/<name>.rb
4
+ # as a class with a #run(argv) method; we load lazily so `fresco --help`
5
+ # doesn't pay for parsing herb / loading the dev loop.
6
+ module Fresco
7
+ class CLI
8
+ COMMANDS = {
9
+ "new" => "Fresco::CLI::New",
10
+ "build" => "Fresco::CLI::Build",
11
+ "dev" => "Fresco::CLI::Dev",
12
+ "release" => "Fresco::CLI::Release",
13
+ }.freeze
14
+
15
+ def self.start(argv)
16
+ cmd = argv.first
17
+
18
+ if cmd.nil? || cmd == "--help" || cmd == "-h"
19
+ print_usage
20
+ return 0
21
+ end
22
+
23
+ if cmd == "--version" || cmd == "-v"
24
+ puts "fresco #{Fresco::VERSION}"
25
+ return 0
26
+ end
27
+
28
+ const_name = COMMANDS[cmd]
29
+ unless const_name
30
+ warn "fresco: unknown command #{cmd.inspect}"
31
+ print_usage
32
+ return 1
33
+ end
34
+
35
+ require "fresco/cli/#{cmd}"
36
+ klass = const_name.split("::").inject(Object) { |m, n| m.const_get(n) }
37
+ klass.new.run(argv[1..])
38
+ end
39
+
40
+ def self.print_usage
41
+ puts <<~USAGE
42
+ Usage: fresco <command> [args]
43
+
44
+ Commands:
45
+ new <name> Scaffold a new Fresco app in ./<name>/
46
+ build Regenerate generated/* from config + app/
47
+ dev Run the CRuby dev loop (build + watch + serve)
48
+ release Build a Spinel-compiled binary at ./build/app
49
+
50
+ Flags:
51
+ -h, --help Show this message
52
+ -v, --version Show fresco version
53
+ USAGE
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,34 @@
1
+ # Build-time + runtime-safe database config slots. Populated by
2
+ # config/database.rb's `Fresco.database :sqlite, ...` (or :postgres)
3
+ # call. `fresco build` reads `database_adapter` to decide which adapter
4
+ # template to copy into `generated/db_adapter.rb`; at runtime the
5
+ # same file is re-loaded as part of the boot chain so ENV.fetch'd
6
+ # values (paths, URLs) resolve in the running binary's environment
7
+ # rather than at build time.
8
+ #
9
+ # Three typed scalars instead of one hash — see the note in
10
+ # lib/fresco/runtime/runtime.rb for the Spinel poly-hash + nil-init traps.
11
+ # Mirrored verbatim there so build-time and runtime stay in sync.
12
+ module Fresco
13
+ @database_adapter = :none
14
+ @database_path = ""
15
+ @database_url = ""
16
+
17
+ def self.database_adapter
18
+ @database_adapter
19
+ end
20
+
21
+ def self.database_path
22
+ @database_path
23
+ end
24
+
25
+ def self.database_url
26
+ @database_url
27
+ end
28
+
29
+ def self.database(adapter, path: "", url: "")
30
+ @database_adapter = adapter
31
+ @database_path = path
32
+ @database_url = url
33
+ end
34
+ end
@@ -0,0 +1,18 @@
1
+ source "https://gem.coop"
2
+
3
+ gem "fresco"<%= fresco_source %>
4
+
5
+ group :development do
6
+ # Lints user code against the Spinel-acceptable Ruby subset.
7
+ # `fresco build` runs this and fails the build on violations.
8
+ gem "rubocop_spinel"
9
+
10
+ # Powers `fresco dev`'s CRuby stand-in for the SQLite FFI shim.
11
+ # Production binaries link libsqlite3 directly via the gem-shipped
12
+ # sqlite.c — this is a dev-only convenience so actions that hit
13
+ # Fresco::Db::Active work under CRuby without a Spinel recompile.
14
+ gem "sqlite3"
15
+
16
+ # Uncomment if you switch the adapter in config/database.rb.
17
+ # gem "pg"
18
+ end
@@ -0,0 +1,32 @@
1
+ # <%= app_name %>
2
+
3
+ A Fresco app — Ruby that compiles to a static binary via Spinel.
4
+
5
+ ## Getting started
6
+
7
+ ```
8
+ bundle install
9
+ bin/dev
10
+ ```
11
+
12
+ Then open <http://localhost:3030/>.
13
+
14
+ ## Layout
15
+
16
+ - `app/actions/` — request handlers. Add a file, point a route at it.
17
+ - `app/views/` — ERB templates compiled at build time.
18
+ - `app/models/` — model declarations (drive generated DB helpers).
19
+ - `config/routes.rb` — route table.
20
+ - `config/database.rb` — adapter + DSN.
21
+ - `db/schema.rb` — table definitions.
22
+ - `db/migrations/` — versioned SQL migrations.
23
+
24
+ ## Commands
25
+
26
+ - `bin/dev` — CRuby dev loop with auto-reload. Edit + refresh.
27
+ - `bin/build` — regenerate `generated/` without booting the server.
28
+ - `bin/release` — compile a production binary to `build/app`.
29
+
30
+ You'll need the `spinel` binary on PATH (or symlinked into this
31
+ directory) for `bin/release`. See the Fresco docs for how to obtain
32
+ it.
@@ -0,0 +1,20 @@
1
+ # Shared base for all actions in this application. Concrete actions
2
+ # under app/actions/ inherit from this rather than Fresco::Action
3
+ # directly, so cross-cutting changes (layouts, filters, helper
4
+ # methods) stay in one place.
5
+ #
6
+ # Filters available from the framework base:
7
+ # - #before_action(req) — override to run pre-call code; call
8
+ # halt!(resp) to short-circuit #call.
9
+ # - #after_action(req, res) — override to run post-call code; call
10
+ # halt!(resp) to replace the response.
11
+ # - #handle(req) + super — wrap the whole pipeline ("around").
12
+ module <%= module_name %>
13
+ class Action < Fresco::Action
14
+ # Default layout for every action. Override `#layout` on a
15
+ # specific action class (or return :none) to opt out per-action.
16
+ def layout
17
+ :application
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,5 @@
1
+ class RootPath < <%= module_name %>::Action
2
+ def call(req = Request.new("", "", ""))
3
+ render(render_root_path)
4
+ end
5
+ end
@@ -0,0 +1,29 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Fresco App</title>
6
+ <style>
7
+ body {
8
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
9
+ max-width: 40rem;
10
+ margin: 2rem auto;
11
+ padding: 0 1.5rem;
12
+ color: #222;
13
+ }
14
+ header nav a { color: #0066cc; text-decoration: none; margin-right: 1rem; }
15
+ header nav a:hover { text-decoration: underline; }
16
+ code { background: #f3f3f3; padding: 0.1rem 0.3rem; border-radius: 3px; }
17
+ footer { color: #888; font-size: 0.85rem; border-top: 1px solid #eee; margin-top: 2rem; padding-top: 1rem; }
18
+ </style>
19
+ </head>
20
+ <body>
21
+ <header>
22
+ <nav><a href="/">home</a></nav>
23
+ </header>
24
+ <main>
25
+ <%= yield %>
26
+ </main>
27
+ <footer><small>Powered by Spinel + Fresco.</small></footer>
28
+ </body>
29
+ </html>
@@ -0,0 +1,8 @@
1
+ <h1>Welcome to Fresco</h1>
2
+ <p>You're up and running. Edit <code>app/views/root_path.html.erb</code> to change this page, or add new actions under <code>app/actions/</code>.</p>
3
+ <p>Next steps:</p>
4
+ <ul>
5
+ <li>Define routes in <code>config/routes.rb</code>.</li>
6
+ <li>Add a database in <code>config/database.rb</code>, then declare your schema in <code>db/schema.rb</code>.</li>
7
+ <li>Run <code>bin/release</code> to produce a Spinel-compiled binary at <code>build/app</code>.</li>
8
+ </ul>
@@ -0,0 +1,15 @@
1
+ # Fresco: entry point. Spinel reads this file; everything reachable
2
+ # via require_relative gets AOT-compiled into the binary.
3
+ #
4
+ # The boot file (auto-generated by `fresco build`) wires the load
5
+ # order: runtime → views → config/app → action manifest → dispatcher.
6
+ # Everything that changes per-app lives in config/, app/, or
7
+ # app/views/ — this file stays a one-liner plus the boot call.
8
+ #
9
+ # Spinel quirk: ARGV is read directly inside Fresco::App rather
10
+ # than passed in — the sp_Argv type doesn't survive being routed
11
+ # through a Ruby parameter.
12
+
13
+ require_relative "generated/boot"
14
+
15
+ <%= module_name %>::Base.run
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bash
2
+ exec bundle exec fresco build "$@"
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bash
2
+ exec bundle exec fresco dev "$@"
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bash
2
+ exec bundle exec fresco release "$@"
@@ -0,0 +1,26 @@
1
+ # Boot-time customization point. `<%= module_name %>::Base.new` is
2
+ # instantiated from app.rb at startup, so anything set in #initialize
3
+ # takes effect before the listener spawns.
4
+
5
+ # Sign signed-session cookies (and anything else that uses HMAC) with
6
+ # the value of $SESSION_SECRET. Falls back to a placeholder for the
7
+ # dev loop so /session works out of the box; production deployments
8
+ # MUST set the env var to a long random string (e.g. 64 hex bytes
9
+ # from `openssl rand -hex 32`). Empty value silently disables
10
+ # sessions entirely.
11
+ #
12
+ # Split the ENV read from the assignment instead of `ENV.fetch(key,
13
+ # default)`: Spinel's two-arg `ENV.fetch` codegen emits a `const
14
+ # char *` ternary but the surrounding context wraps the result in
15
+ # `sp_box_str`, producing an "assigning to 'const char *' from
16
+ # incompatible type 'sp_RbVal'" compile error at the setter site.
17
+ session_secret = ENV["SESSION_SECRET"]
18
+ if session_secret.nil? || session_secret.empty?
19
+ session_secret = "dev-only-INSECURE-session-secret-change-me-for-prod"
20
+ end
21
+ Fresco.set_session_secret(session_secret)
22
+
23
+ module <%= module_name %>
24
+ class Base < Fresco::App
25
+ end
26
+ end
@@ -0,0 +1,17 @@
1
+ # Fresco: database configuration. Loaded twice — once by `fresco
2
+ # build` to capture the adapter symbol (drives the codegen template
3
+ # selection in generated/db_adapter.rb), and once at runtime as part
4
+ # of boot.rb so ENV.fetch'd values resolve in the running binary's
5
+ # environment rather than at build time.
6
+ #
7
+ # Switching adapters requires re-running `fresco build` (the linker
8
+ # picks one C shim or the other; we don't link both). For SQLite, the
9
+ # path can be `:memory:` for an ephemeral in-process DB, or any
10
+ # filesystem path. For Postgres, pass any libpq conninfo string —
11
+ # `postgres://user@host/db` or `host=... user=... dbname=...`.
12
+
13
+ Fresco.database :sqlite,
14
+ path: ENV.fetch("DATABASE_PATH", "db/app.sqlite3")
15
+
16
+ # Fresco.database :postgres,
17
+ # url: ENV.fetch("DATABASE_URL", "postgres://localhost/app_dev")
@@ -0,0 +1,11 @@
1
+ # Fresco: route definitions.
2
+ #
3
+ # Evaluated by `fresco build` under CRuby. The block captures (verb,
4
+ # pattern, action_class) triples on Fresco.app.routes; build emits
5
+ # generated/dispatch.rb from them. Not loaded at runtime —
6
+ # production reads the generated dispatcher.
7
+
8
+ Fresco.app.routes do
9
+ # Uncomment and edit the line below to define your app's root route. Example:
10
+ # root to: RootPath
11
+ end
@@ -0,0 +1,14 @@
1
+ # Fresco: schema declaration. Loaded by `fresco build` to drive model
2
+ # codegen. Add tables with:
3
+ #
4
+ # Fresco.schema do
5
+ # table :users do
6
+ # column :id, :int, primary_key: true
7
+ # column :email, :str, null: false, index: :unique
8
+ # end
9
+ # end
10
+ #
11
+ # Runtime never loads this file — generated/models/*.rb are
12
+ # self-contained.
13
+ Fresco.schema do
14
+ end
@@ -0,0 +1,87 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>404 — Not Found</title>
6
+ <link rel="preconnect" href="https://fonts.googleapis.com">
7
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
8
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;700&display=swap" rel="stylesheet">
9
+ <script>
10
+ // Pre-paint so the page doesn't flash light before JS runs. If
11
+ // the user has no stored preference we seed from the OS, then
12
+ // persist so subsequent visits skip the matchMedia probe.
13
+ (function () {
14
+ var t = localStorage.getItem("theme");
15
+ if (!t) {
16
+ t = (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) ? "dark" : "light";
17
+ localStorage.setItem("theme", t);
18
+ }
19
+ document.documentElement.setAttribute("data-theme", t);
20
+ })();
21
+ </script>
22
+ <style>
23
+ :root {
24
+ --bg: #fff; --fg: #222; --muted: #888;
25
+ --border: #eee; --code-bg: #f7f7f7;
26
+ --accent: #0066cc; --status: #888;
27
+ }
28
+ :root[data-theme="dark"] {
29
+ --bg: #141414; --fg: #e8e8e8; --muted: #9a9a9a;
30
+ --border: #2a2a2a; --code-bg: #1f1f1f;
31
+ --accent: #6ab0ff; --status: #b8b8b8;
32
+ }
33
+ body {
34
+ font-family: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
35
+ max-width: 40rem;
36
+ margin: 4rem auto;
37
+ padding: 0 1.5rem;
38
+ background: var(--bg);
39
+ color: var(--fg);
40
+ transition: background 0.15s, color 0.15s;
41
+ }
42
+ h1 { font-weight: 700; font-size: 1.5rem; margin-bottom: 0.25rem; }
43
+ .status { color: var(--status); font-size: 0.9rem; letter-spacing: 0.05em; text-transform: uppercase; }
44
+ p { line-height: 1.5; }
45
+ code { background: var(--code-bg); padding: 0.1rem 0.3rem; border-radius: 3px; }
46
+ a { color: var(--accent); }
47
+ a:hover { text-decoration: underline; }
48
+ hr { border: none; border-top: 1px solid var(--border); margin: 2rem 0; }
49
+ footer { color: var(--muted); font-size: 0.85rem; }
50
+ #theme-toggle {
51
+ position: fixed; top: 1rem; right: 1rem;
52
+ background: transparent; border: 1px solid var(--border);
53
+ color: var(--fg); border-radius: 6px;
54
+ padding: 0.4rem; cursor: pointer;
55
+ display: inline-flex; align-items: center; justify-content: center;
56
+ }
57
+ #theme-toggle:hover { background: var(--code-bg); }
58
+ #theme-toggle svg { width: 18px; height: 18px; display: none; }
59
+ :root[data-theme="light"] #theme-toggle .icon-moon { display: inline-block; }
60
+ :root[data-theme="dark"] #theme-toggle .icon-sun { display: inline-block; }
61
+ </style>
62
+ </head>
63
+ <body>
64
+ <button id="theme-toggle" aria-label="Toggle color theme" title="Toggle color theme">
65
+ <svg class="icon-sun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>
66
+ <svg class="icon-moon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
67
+ </button>
68
+
69
+ <div class="status">404 &middot; Not Found</div>
70
+ <h1>That page isn't here.</h1>
71
+ <p>The URL you requested didn't match any route, or the file you asked for doesn't exist under <code>public/</code>.</p>
72
+ <p><a href="/">Back to home</a></p>
73
+ <hr>
74
+ <footer>Powered by Spinel + Fresco.</footer>
75
+
76
+ <script>
77
+ (function () {
78
+ document.getElementById("theme-toggle").addEventListener("click", function () {
79
+ var cur = document.documentElement.getAttribute("data-theme");
80
+ var nxt = cur === "dark" ? "light" : "dark";
81
+ document.documentElement.setAttribute("data-theme", nxt);
82
+ localStorage.setItem("theme", nxt);
83
+ });
84
+ })();
85
+ </script>
86
+ </body>
87
+ </html>