WIP: implementer stopped mid-task (weekly model limit); untested
Saved as-is by the coordinating session so the work isn't lost. The next implementer continues from here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
43 files changed,
+1215
−587
cmd/slides/main.go+10 −3go.mod+3 −2go.sum+4 −4internal/slides/agents.go+4 −1internal/slides/app.go+32 −4internal/slides/auth.go+264 −30internal/slides/chrome.go+258 −0internal/slides/credits.go+21 −6internal/slides/decks.go+7 −34internal/slides/editor.go+6 −3internal/slides/errors.go+246 −0internal/slides/exports.go+2 −2internal/slides/handlers.go+9 −0internal/slides/home.go+23 −16internal/slides/locales/en.toml+80 −42internal/slides/presentation.go+37 −42internal/slides/render.go+96 −76internal/slides/share.go+13 −2internal/slides/templates/account.html+0 −50internal/slides/templates/agents.html+1 −2internal/slides/templates/anon.html+0 −16internal/slides/templates/credits.html+1 −3internal/slides/templates/deck_delete.html+0 −1internal/slides/templates/deck_export.html+1 −2internal/slides/templates/deck_new.html+0 −1internal/slides/templates/deck_notes.html+1 −2internal/slides/templates/deck_settings.html+0 −1internal/slides/templates/decks.html+0 −1internal/slides/templates/editor.html+0 −1internal/slides/templates/errors.html+8 −16internal/slides/templates/export_status.html+0 −1internal/slides/templates/help.html+0 −4internal/slides/templates/home_link.html+0 −1internal/slides/templates/layout.html+51 −83internal/slides/templates/present.html+0 −1internal/slides/templates/present_notes.html+1 −2internal/slides/templates/remote.html+0 −1internal/slides/templates/remote_pair.html+0 −1internal/slides/templates/share.html+2 −3internal/slides/templates/signed_out.html+6 −0internal/slides/templates/signin.html+13 −109internal/slides/templates/viewer.html+1 −2internal/slides/viewer.go+14 −16
CI failed — run details
CI log
=== gate ===
=== rust: fmt
=== rust: clippy
Compiling proc-macro2 v1.0.107
Compiling unicode-ident v1.0.24
Compiling quote v1.0.47
Compiling serde_core v1.0.229
Compiling zerocopy v0.8.56
Compiling version_check v0.9.5
Checking cfg-if v1.0.4
Compiling serde v1.0.229
Checking once_cell v1.21.4
Checking smallvec v1.15.2
Compiling libc v0.2.189
Compiling crossbeam-utils v0.8.22
Compiling autocfg v1.5.1
Checking parking v2.2.1
Checking pin-project-lite v0.2.17
Compiling parking_lot_core v0.9.12
Compiling zmij v1.0.23
Compiling rustversion v1.0.23
Checking event-listener v5.4.2
Compiling thiserror v2.0.20
Compiling libm v0.2.16
Compiling serde_json v1.0.151
Compiling paste v1.0.15
Checking scopeguard v1.2.0
Compiling ahash v0.8.12
Checking event-listener-strategy v0.5.4
Checking memchr v2.8.3
Checking lock_api v0.4.14
Checking foldhash v0.2.0
Checking itoa v1.0.18
Compiling num-traits v0.2.19
Checking async-lock v3.4.2
Checking smallstr v0.3.1
Checking fastrand v2.5.0 (/home/paulca/.local/state/amadan/runner/repos/oficina__slides/wt/feat__r2-shared-chrome/core/vendor/fastrand-2.5.0)
Checking downcast-rs v1.2.1
Checking indexmap-nostd v0.4.0
Checking equivalent v1.0.2
Checking bitflags v2.13.1
Checking spin v0.9.9
Checking arrayvec v0.7.8
Checking semver v1.0.28
Checking multi-stash v0.2.0
Checking wasmparser-nostd v0.100.2
Checking arc-swap v1.9.2
Compiling syn v3.0.4
Compiling syn v2.0.119
Checking wasmi_core v0.32.3
Checking hashbrown v0.17.1
Compiling num-derive v0.4.2
Checking indexmap v2.14.1
Compiling serde_derive v1.0.229
Compiling thiserror-impl v2.0.20
Compiling async-trait v0.1.92
Checking hashbrown v0.14.5
Checking dashmap v6.2.1
Checking string-interner v0.17.0
Checking yrs v0.23.5
Checking wasmparser v0.258.0
Checking wasmi_collections v0.32.3
Checking wasmi v0.32.3
Checking slides-core v0.1.0 (/home/paulca/.local/state/amadan/runner/repos/oficina__slides/wt/feat__r2-shared-chrome/core)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 8.71s
=== wasm: the artifact the host embeds
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.03s
Compiling proc-macro2 v1.0.107
Compiling quote v1.0.47
Compiling unicode-ident v1.0.24
Compiling serde_core v1.0.229
Compiling cfg-if v1.0.4
Compiling rustversion v1.0.23
Compiling smallvec v1.15.2
Compiling parking_lot_core v0.9.12
Compiling pin-project-lite v0.2.17
Compiling getrandom v0.4.3
Compiling zmij v1.0.23
Compiling crossbeam-utils v0.8.22
Compiling serde v1.0.229
Compiling thiserror v2.0.20
Compiling serde_json v1.0.151
Compiling scopeguard v1.2.0
Compiling memchr v2.8.3
Compiling event-listener v5.4.2
Compiling lock_api v0.4.14
Compiling hashbrown v0.14.5
Compiling once_cell v1.21.4
Compiling itoa v1.0.18
Compiling event-listener-strategy v0.5.4
Compiling smallstr v0.3.1
Compiling fastrand v2.5.0 (/home/paulca/.local/state/amadan/runner/repos/oficina__slides/wt/feat__r2-shared-chrome/core/vendor/fastrand-2.5.0)
Compiling async-lock v3.4.2
Compiling arc-swap v1.9.2
Compiling dashmap v6.2.1
Compiling syn v3.0.4
Compiling thiserror-impl v2.0.20
Compiling serde_derive v1.0.229
Compiling async-trait v0.1.92
Compiling yrs v0.23.5
Compiling slides-core v0.1.0 (/home/paulca/.local/state/amadan/runner/repos/oficina__slides/wt/feat__r2-shared-chrome/core)
Finished `release` profile [optimized] target(s) in 11.82s
core/target/wasm32-unknown-unknown/release/slides_core.wasm (1228229 bytes)
=== the embedded wasm must match a fresh build
=== rust: test
Compiling serde_core v1.0.229
Compiling cfg-if v1.0.4
Compiling once_cell v1.21.4
Compiling smallvec v1.15.2
Compiling parking v2.2.1
Compiling pin-project-lite v0.2.17
Compiling zerocopy v0.8.56
Compiling libc v0.2.189
Compiling scopeguard v1.2.0
Compiling crossbeam-utils v0.8.22
Compiling zmij v1.0.23
Compiling memchr v2.8.3
Compiling itoa v1.0.18
Compiling foldhash v0.2.0
Compiling libm v0.2.16
Compiling thiserror v2.0.20
Compiling num-traits v0.2.19
Compiling arc-swap v1.9.2
Compiling event-listener v5.4.2
Compiling lock_api v0.4.14
Compiling downcast-rs v1.2.1
Compiling smallstr v0.3.1
Compiling equivalent v1.0.2
Compiling indexmap-nostd v0.4.0
Compiling fastrand v2.5.0 (/home/paulca/.local/state/amadan/runner/repos/oficina__slides/wt/feat__r2-shared-chrome/core/vendor/fastrand-2.5.0)
Compiling bitflags v2.13.1
Compiling multi-stash v0.2.0
Compiling event-listener-strategy v0.5.4
Compiling arrayvec v0.7.8
Compiling semver v1.0.28
Compiling spin v0.9.9
Compiling wasmparser-nostd v0.100.2
Compiling async-lock v3.4.2
Compiling wasmi_core v0.32.3
Compiling parking_lot_core v0.9.12
Compiling serde v1.0.229
Compiling serde_json v1.0.151
Compiling hashbrown v0.17.1
Compiling indexmap v2.14.1
Compiling wasmparser v0.258.0
Compiling ahash v0.8.12
Compiling hashbrown v0.14.5
Compiling dashmap v6.2.1
Compiling string-interner v0.17.0
Compiling wasmi_collections v0.32.3
Compiling yrs v0.23.5
Compiling wasmi v0.32.3
Compiling slides-core v0.1.0 (/home/paulca/.local/state/amadan/runner/repos/oficina__slides/wt/feat__r2-shared-chrome/core)
Finished `test` profile [unoptimized + debuginfo] target(s) in 14.51s
Running unittests src/lib.rs (core/target/debug/deps/slides_core-929bfceb2edd4a94)
running 30 tests
test check::tests::the_empty_document_passes ... ok
test layout::tests::aside_region_rect ... ok
test layout::tests::default_region_by_kind ... ok
test layout::tests::body_region_rect_narrows_for_an_aside ... ok
test layout::tests::heading_region_rect ... ok
test layout::tests::media_region_rect ... ok
test layout::tests::to_canvas_narrows_body_when_an_aside_is_present ... ok
test layout::tests::to_canvas_splits_a_shared_region_equally_and_sets_z_by_index ... ok
test check::tests::an_element_inside_a_paragraph_is_reported ... ok
test layout::tests::to_flow_is_a_no_op_when_already_in_order ... ok
test check::tests::bare_text_at_fragment_level_is_reported ... ok
test layout::tests::to_flow_classifies_by_position_and_kind ... ok
test check::tests::what_the_ops_write_passes ... ok
test check::tests::a_non_paragraph_child_is_reported ... ok
test check::tests::y_prosemirror_shapes_pass ... ok
test markdown::tests::empty_input_is_no_paragraphs_and_lines_keep_blanks ... ok
test markdown::tests::inline_marks ... ok
test markdown::tests::marks_nest_and_links_carry_marks ... ok
test markdown::tests::paragraphs_split_on_blank_lines_and_list_markers ... ok
test check::tests::an_unknown_formatting_attribute_is_reported ... ok
test schema::tests::an_empty_map_reads_as_a_set_mark ... ok
test schema::tests::an_href_map_without_a_string_url_reads_as_no_link ... ok
test schema::tests::href_is_written_as_a_one_key_map_and_reads_back ... ok
test layout::tests::to_flow_orders_y_then_x ... ok
test schema::tests::href_written_as_a_plain_string_still_reads_back ... ok
test view::tests::malformed_background_is_the_default ... ok
test view::tests::assets_are_typed_or_skipped ... ok
test view::tests::malformed_box_is_null_and_phone_keeps_only_typed_fields ... ok
test view::tests::malformed_fonts_are_null ... ok
test layout::tests::to_flow_keeps_a_concurrent_text_edit ... ok
test result: ok. 30 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests/no_host_imports.rs (core/target/debug/deps/no_host_imports-184c0d8bf00e14f9)
running 1 test
test no_host_imports ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests/ops.rs (core/target/debug/deps/ops-256addce27f366d1)
running 12 tests
test add_slide_then_view_lists_it ... ok
test add_block_with_markdown_produces_runs ... ok
test layout_switch_assigns_boxes ... ok
test duplicate_slide_remaps_columns_children ... ok
test columns_children_have_one_parent_and_no_cycles ... ok
test move_slide_keeps_a_concurrent_text_edit ... ok
test move_block_keeps_a_concurrent_text_edit ... ok
test columns_children_must_name_blocks_on_the_slide ... ok
test ops_are_atomic ... ok
test remove_block_drops_it_from_columns_children ... ok
test update_roundtrip ... ok
test remaining_ops_roundtrip ... ok
test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
Doc-tests slides_core
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
=== rust: fmt (pdf)
=== rust: clippy (pdf)
Compiling proc-macro2 v1.0.107
Compiling quote v1.0.47
Compiling unicode-ident v1.0.24
Compiling autocfg v1.5.1
Checking log v0.4.34
Compiling serde_core v1.0.229
Compiling libm v0.2.16
Checking arrayvec v0.7.8
Checking simd-adler32 v0.3.10
Compiling crc32fast v1.5.1
Checking adler2 v2.0.1
Checking bitflags v2.13.1
Checking smallvec v1.16.0
Checking cfg-if v1.0.4
Compiling libc v0.2.189
Checking foldhash v0.2.0
Compiling version_check v0.9.5
Checking arrayref v0.3.9
Checking memchr v2.8.3
Checking equivalent v1.0.2
Checking roxmltree v0.20.0
Checking unicode-properties v0.1.4
Checking unicode-bidi-mirroring v0.4.0
Checking unicode-script v0.5.8
Checking miniz_oxide v0.9.1
Checking polycool v0.4.0
Checking miniz_oxide v0.8.9
Checking fdeflate v0.3.7
Checking unicode-ccc v0.4.0
Checking tinyvec_macros v0.1.1
Checking siphasher v1.0.3
Checking tinyvec v1.13.2
Checking kurbo v0.13.1
Checking weezl v0.1.12
Compiling slotmap v1.1.1
Checking fontconfig-parser v0.5.8
Compiling num-traits v0.2.19
Checking imagesize v0.14.0
Checking quick-error v2.0.1
Checking base64 v0.22.1
Checking zune-core v0.5.3
Checking color_quant v1.1.0
Checking byteorder-lite v0.1.0
Checking flate2 v1.1.10
Checking roxmltree v0.21.1
Checking simplecss v0.2.2
Checking ryu v1.0.23
Checking gif v0.14.2
Checking unicode-vo v0.1.0
Checking rustc-hash v2.1.3
Checking itoa v1.0.18
Checking image-webp v0.2.4
Checking zune-jpeg v0.5.15
Checking xmlwriter v0.1.0
Checking pico-args v0.5.0
Checking unicode-bidi v0.3.18
Checking data-url v0.3.2
Checking png v0.18.1
Checking stable_deref_trait v1.2.1
Checking pdf-writer v0.15.0
Compiling serde v1.0.229
Compiling syn v3.0.5
Compiling syn v2.0.119
Checking bumpalo v3.20.3
Checking svgtypes v0.16.1
Checking xmp-writer v0.3.3
Checking core_maths v0.1.1
Checking semver v1.0.28
Checking ttf-parser v0.25.1
Checking memmap2 v0.9.11
Checking float-cmp v0.9.0
Checking strict-num v0.1.1
Checking hashbrown v0.17.1
Checking indexmap v2.14.2
Compiling bytemuck_derive v1.12.0
Checking fontdb v0.23.0
Compiling synstructure v0.13.2
Checking wasmparser v0.258.0
Compiling zerofrom-derive v0.1.7
Compiling yoke-derive v0.8.2
Checking bytemuck v1.25.2
Checking font-types v0.11.3
Checking tiny-skia-path v0.12.0
Checking rustybuzz v0.20.1
Checking rgb v0.8.53
Checking zerofrom v0.1.8
Checking yoke v0.8.3
Checking read-fonts v0.39.2
Checking tiny-skia v0.12.0
Checking usvg v0.47.0
Checking resvg v0.47.0
Checking skrifa v0.42.1
Checking write-fonts v0.48.1
Checking subsetter v0.2.6
Checking krilla v0.8.2
Checking krilla-svg v0.8.1
Checking slides-pdf v0.1.0 (/home/paulca/.local/state/amadan/runner/repos/oficina__slides/wt/feat__r2-shared-chrome/core/pdf)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 11.29s
=== wasm: the pdf artifact the host embeds
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.03s
Compiling proc-macro2 v1.0.107
Compiling quote v1.0.47
Compiling unicode-ident v1.0.24
Compiling autocfg v1.5.1
Compiling simd-adler32 v0.3.10
Compiling log v0.4.34
Compiling arrayvec v0.7.8
Compiling libm v0.2.16
Compiling smallvec v1.16.0
Compiling crc32fast v1.5.1
Compiling adler2 v2.0.1
Compiling bitflags v2.13.1
Compiling cfg-if v1.0.4
Compiling version_check v0.9.5
Compiling arrayref v0.3.9
Compiling memchr v2.8.3
Compiling siphasher v1.0.3
Compiling fdeflate v0.3.7
Compiling tinyvec_macros v0.1.1
Compiling miniz_oxide v0.9.1
Compiling miniz_oxide v0.8.9
Compiling polycool v0.4.0
Compiling unicode-script v0.5.8
Compiling unicode-bidi-mirroring v0.4.0
Compiling unicode-ccc v0.4.0
Compiling hashbrown v0.17.1
Compiling equivalent v1.0.2
Compiling slotmap v1.1.1
Compiling unicode-properties v0.1.4
Compiling kurbo v0.13.1
Compiling tinyvec v1.13.2
Compiling byteorder-lite v0.1.0
Compiling num-traits v0.2.19
Compiling quick-error v2.0.1
Compiling weezl v0.1.12
Compiling color_quant v1.1.0
Compiling flate2 v1.1.10
Compiling imagesize v0.14.0
Compiling memmap2 v0.9.11
Compiling base64 v0.22.1
Compiling zune-core v0.5.3
Compiling indexmap v2.14.2
Compiling gif v0.14.2
Compiling image-webp v0.2.4
Compiling roxmltree v0.21.1
Compiling syn v3.0.5
Compiling syn v2.0.119
Compiling png v0.18.1
Compiling float-cmp v0.9.0
Compiling core_maths v0.1.1
Compiling ttf-parser v0.25.1
Compiling strict-num v0.1.1
Compiling svgtypes v0.16.1
Compiling zune-jpeg v0.5.15
Compiling simplecss v0.2.2
Compiling ryu v1.0.23
Compiling data-url v0.3.2
Compiling rustc-hash v2.1.3
Compiling unicode-bidi v0.3.18
Compiling itoa v1.0.18
Compiling stable_deref_trait v1.2.1
Compiling pico-args v0.5.0
Compiling unicode-vo v0.1.0
Compiling xmlwriter v0.1.0
Compiling pdf-writer v0.15.0
Compiling xmp-writer v0.3.3
Compiling bumpalo v3.20.3
Compiling bytemuck_derive v1.12.0
Compiling synstructure v0.13.2
Compiling fontdb v0.23.0
Compiling zerofrom-derive v0.1.7
Compiling yoke-derive v0.8.2
Compiling bytemuck v1.25.2
Compiling zerofrom v0.1.8
Compiling font-types v0.11.3
Compiling tiny-skia-path v0.12.0
Compiling rustybuzz v0.20.1
Compiling rgb v0.8.53
Compiling yoke v0.8.3
Compiling read-fonts v0.39.2
Compiling tiny-skia v0.12.0
Compiling usvg v0.47.0
Compiling resvg v0.47.0
Compiling skrifa v0.42.1
Compiling write-fonts v0.48.1
Compiling subsetter v0.2.6
Compiling krilla v0.8.2
Compiling krilla-svg v0.8.1
Compiling slides-pdf v0.1.0 (/home/paulca/.local/state/amadan/runner/repos/oficina__slides/wt/feat__r2-shared-chrome/core/pdf)
Finished `release` profile [optimized] target(s) in 22.75s
core/pdf/target/wasm32-unknown-unknown/release/slides_pdf.wasm (5301002 bytes)
=== the embedded pdf wasm must match a fresh build
=== rust: test (pdf)
Compiling bytemuck v1.25.2
Compiling log v0.4.34
Compiling arrayvec v0.7.8
Compiling simd-adler32 v0.3.10
Compiling bitflags v2.13.1
Compiling smallvec v1.16.0
Compiling adler2 v2.0.1
Compiling cfg-if v1.0.4
Compiling foldhash v0.2.0
Compiling libm v0.2.16
Compiling num-traits v0.2.19
Compiling serde_core v1.0.229
Compiling equivalent v1.0.2
Compiling memchr v2.8.3
Compiling arrayref v0.3.9
Compiling libc v0.2.189
Compiling unicode-bidi-mirroring v0.4.0
Compiling crc32fast v1.5.1
Compiling tinyvec_macros v0.1.1
Compiling unicode-script v0.5.8
Compiling siphasher v1.0.3
Compiling unicode-properties v0.1.4
Compiling roxmltree v0.20.0
Compiling polycool v0.4.0
Compiling miniz_oxide v0.9.1
Compiling fdeflate v0.3.7
Compiling miniz_oxide v0.8.9
Compiling font-types v0.11.3
Compiling unicode-ccc v0.4.0
Compiling tinyvec v1.13.2
Compiling slotmap v1.1.1
Compiling weezl v0.1.12
Compiling kurbo v0.13.1
Compiling color_quant v1.1.0
Compiling quick-error v2.0.1
Compiling base64 v0.22.1
Compiling read-fonts v0.39.2
Compiling fontconfig-parser v0.5.8
Compiling byteorder-lite v0.1.0
Compiling zune-core v0.5.3
Compiling imagesize v0.14.0
Compiling gif v0.14.2
Compiling float-cmp v0.9.0
Compiling flate2 v1.1.10
Compiling image-webp v0.2.4
Compiling strict-num v0.1.1
Compiling zune-jpeg v0.5.15
Compiling core_maths v0.1.1
Compiling memmap2 v0.9.11
Compiling ttf-parser v0.25.1
Compiling tiny-skia-path v0.12.0
Compiling roxmltree v0.21.1
Compiling zerofrom v0.1.8
Compiling simplecss v0.2.2
Compiling rustc-hash v2.1.3
Compiling pico-args v0.5.0
Compiling data-url v0.3.2
Compiling png v0.18.1
Compiling svgtypes v0.16.1
Compiling xmlwriter v0.1.0
Compiling unicode-vo v0.1.0
Compiling unicode-bidi v0.3.18
Compiling stable_deref_trait v1.2.1
Compiling itoa v1.0.18
Compiling ryu v1.0.23
Compiling yoke v0.8.3
Compiling rgb v0.8.53
Compiling pdf-writer v0.15.0
Compiling bumpalo v3.20.3
Compiling xmp-writer v0.3.3
Compiling semver v1.0.28
Compiling hashbrown v0.17.1
Compiling serde v1.0.229
Compiling tiny-skia v0.12.0
Compiling indexmap v2.14.2
Compiling rustybuzz v0.20.1
Compiling fontdb v0.23.0
Compiling wasmparser v0.258.0
Compiling usvg v0.47.0
Compiling resvg v0.47.0
Compiling write-fonts v0.48.1
Compiling skrifa v0.42.1
Compiling subsetter v0.2.6
Compiling krilla v0.8.2
Compiling krilla-svg v0.8.1
Compiling slides-pdf v0.1.0 (/home/paulca/.local/state/amadan/runner/repos/oficina__slides/wt/feat__r2-shared-chrome/core/pdf)
Finished `test` profile [unoptimized + debuginfo] target(s) in 17.63s
Running unittests src/lib.rs (core/pdf/target/debug/deps/slides_pdf-891fafaa44f9fd64)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests/no_host_imports.rs (core/pdf/target/debug/deps/no_host_imports-17ccbb1d62120093)
running 1 test
test no_host_imports ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
Running tests/render.rs (core/pdf/target/debug/deps/render-2e1d7e8b19d78595)
running 6 tests
test raster_image_is_embedded ... ok
test a_null_zero_length_pair_is_no_frames_not_a_dereference ... ok
test text_with_no_fonts_at_all_still_renders_the_shapes ... ok
test renders_text_as_embedded_glyphs ... ok
test abi_frames_round_trip ... ok
test two_svgs_make_two_pages ... ok
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s
Doc-tests slides_pdf
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
=== fixtures: node dependencies
v24.19.0
11.17.0
=== fixtures: npm ci (needs the registry — see .amadan/README.md)
added 19 packages, and audited 20 packages in 2s
5 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
npm warn allow-scripts 1 package has install scripts not yet covered by allowScripts:
npm warn allow-scripts esbuild@0.28.2 (postinstall: node install.js)
npm warn allow-scripts
npm warn allow-scripts Run `npm approve-scripts --allow-scripts-pending` to review, or `npm approve-scripts <pkg>` to allow.
=== fixtures: the JS side's own tests
✔ one finished upload is register_asset plus the block that uses it, in one batch (11.600034ms)
✔ a record missing its numbers still makes an op the core accepts (1.298247ms)
✔ unusedAssets is what no slide refers to, and remove_asset drops one (2.886394ms)
✔ the Unused images list reconciles by id and names each Remove button (1.193943ms)
✔ an upload refusal is said in words the person can act on (0.264072ms)
✔ clampBox keeps the box inside the frame and no smaller than the minimum (1.796264ms)
✔ fixBox writes four decimals (0.318172ms)
✔ guides are the thirds plus every other block edge (0.273659ms)
✔ snapMove pulls the nearest edge onto a guide within one per cent, and leaves a far one (0.535136ms)
✔ snapResize moves only the edges the handle drags (0.269292ms)
✔ moveBox and resizeBox by handle (0.303986ms)
✔ nudge: arrows move by half a per cent, Shift by two, Alt resizes (0.260615ms)
✔ rectBox reads a block rectangle against its frame (0.305208ms)
✔ typing into a block is a text change on its slide only (9.55099ms)
✔ a block's props changing is structure on its slide (1.603044ms)
✔ adding, moving or removing a block is structure on its slide (2.039457ms)
✔ the notes are text on their slide (1.04197ms)
✔ a slide added, removed or moved is an order change (1.602062ms)
✔ one transaction touching several things reports each once (1.023284ms)
✔ what the ops write passes, and so does an empty document (9.172857ms)
✔ the shapes y-prosemirror and older documents write pass (1.83678ms)
✔ an unknown formatting attribute is reported with the core's message (2.137389ms)
✔ an element inside a paragraph, a non-paragraph child and bare text are reported (3.025982ms)
✔ the unknownmark poison shape is what the check refuses (1.787959ms)
✔ y-prosemirror 1.3.7 deletes the words under an unknown mark; sanitising first keeps them (5.299697ms)
✔ the vocabulary is the five marks and nothing else (0.130633ms)
✔ an empty body is no frames (1.580042ms)
✔ frames come back in order with their exact bytes (0.378194ms)
✔ a length prefix is big-endian (0.181558ms)
✔ a truncated frame throws rather than returning a short update (4.784315ms)
✔ deck controls are set_meta (13.146174ms)
✔ slide controls are set_slide, and background keeps its colour (3.329008ms)
✔ move up and down are move_slide with the right after (2.136557ms)
✔ block props: value kinds, region, a shape label (2.753495ms)
✔ box fields are set_block on the desktop and set_phone in the phone view (6.229599ms)
✔ move block and the layer buttons (3.653992ms)
✔ add block: region after the selection on flow, a default box on canvas, a heading level that fits (11.949556ms)
✔ a block is named by its type and its first words (0.975546ms)
✔ blockChange: a control element to its op (1.909545ms)
✔ the Blocks list reconciles by id and keeps its buttons (1.060564ms)
✔ open resolves to the button that closed it and the trimmed field (2.587547ms)
✔ a second open while the dialog is up joins the first (0.271104ms)
✔ a missing dialog mounts as nothing (0.158014ms)
✔ a tab advances its own cursor only when its POST is the very next row (3.380253ms)
✔ the cursor never moves backwards when a response is overtaken by this tab's own write (0.512694ms)
✔ a later response still carries the cursor forward (0.302813ms)
✔ a burst of notifications collapses into one fetch at a time (0.251088ms)
✔ an event naming a seq the tab already has fetches nothing (0.207817ms)
✔ a stopped subscriber drops a response that arrives late (0.814536ms)
✔ stop closes the stream, and start opens exactly one (0.313414ms)
✔ a hidden tab closes its stream on a shortened deadline, and activity reopens it (1.069501ms)
✔ switching away and back inside the grace never drops the stream, so nothing has to be caught up (0.661371ms)
✔ a visible tab that is only being READ closes when idle and comes back on any activity (1.215112ms)
✔ activity while streaming defers the idle close rather than reopening anything (0.568749ms)
✔ a stream failure is never reported as a save failure, and heals on activity (0.254163ms)
✔ an empty response advances the cursor without applying anything (0.440871ms)
✔ a failed fetch leaves the cursor alone so the next notification retries it (0.626958ms)
✔ a decode failure retries once through the raw log, then gives up (1.85871ms)
✔ updatesURL spells the query string exactly, in particular raw as the literal raw=1 (0.174384ms)
✔ empty_doc_views_as_defaults (4.443152ms)
✔ set_meta (3.341409ms)
✔ slides: add, move, remove, set_slide, set_notes (7.985607ms)
✔ duplicate_slide (3.954821ms)
✔ blocks: add, move, remove, set_block (4.683469ms)
✔ set_phone (1.481017ms)
✔ table cells follow rows and cols (1.802606ms)
✔ canvas blocks: box defaults from the region, z from the index (1.966721ms)
✔ assets: register and remove (2.60015ms)
✔ markdown: every mark, code blocks are plain lines (6.515951ms)
✔ batch_is_atomic (2.222597ms)
✔ a batch applies in one transaction with the given origin (0.800189ms)
✔ columns_children_validated (1.675779ms)
✔ layout_switch_round_trip (2.185489ms)
✔ view_matches_snapshot_fixtures (18.437033ms)
✔ columns children have one parent and no cycles (codex 7) (1.312073ms)
✔ canvas-to-flow keeps a concurrent text edit (codex 4) (3.691471ms)
✔ move_block keeps a concurrent text edit, and later inserts land where after says (codex 4) (3.358503ms)
✔ move_slide keeps a concurrent text edit, and later inserts land where after says (codex 4 at slide level) (2.582899ms)
✔ format: {name} and {n} interpolate; an unknown key is left as text (0.974875ms)
✔ a beat on connect, then one every twenty seconds (2.253496ms)
✔ a move posts once, 200ms after the last of a burst, and re-arms the beat (0.383855ms)
✔ a hidden tab stops beating; coming back posts at once (0.288857ms)
✔ applySet leaves this tab out and answers what changed (0.630774ms)
✔ a set with rubbish in it is read for what it does carry (0.281464ms)
✔ initials, and a client id per tab (4.856622ms)
✔ a resync that fails is retried with backoff until it succeeds, without ever running twice at once (9.6746ms)
✔ the browser's online event retries immediately, not just on the next backoff tick (0.37607ms)
✔ reportDivergence before any failure runs the first attempt without a caller ever calling online (3.41675ms)
✔ a permanent 4xx drops its batch instead of retrying it, and does not poison the next edit (83.959102ms)
✔ a network failure or a 5xx is retried, not dropped (60.007361ms)
✔ a 500 is retried the same way a network failure is, never dropped (60.728374ms)
✔ the core-written structure reads as ProseMirror marks (10.531346ms)
✔ a y-prosemirror round trip preserves bold and the link (3.304182ms)
✔ y-prosemirror writes href as the one-key map and marks as {} (1.057198ms)
✔ a plain-string href, written before the map shape, still reads (0.82753ms)
✔ textFragment writes the same shape as writeText (0.570241ms)
✔ safeHref is the server allowlist, and a disallowed href never draws an anchor (0.448004ms)
✔ parseHash splits the slide and the block, and survives a stray % (2.363109ms)
✔ destroy takes the listeners off, and a second mount leaves one of each (0.713759ms)
✔ formatHash is the inverse (1.975749ms)
✔ textBlocks finds the text-bearing blocks and skips the rest (2.506265ms)
✔ mountTarget is the element whose children are the paragraphs (0.282546ms)
✔ paragraphTag keeps the renderer's own markup per type (0.193049ms)
✔ blockFragment finds a block's text by slide and block id (8.753546ms)
✔ notesFragment finds the slide's notes (1.452995ms)
✔ blockIDs and slideIDs are the orders on the stage and the strip (5.508696ms)
✔ cellFragments are a table's cells, row-major (1.733648ms)
✔ a load that fails leaves the stage on the slide it was showing and says so (6.164174ms)
✔ slideAfter is the slide to show once one is gone (0.602261ms)
✔ the queue words map to the four states, failed staying failed (0.822922ms)
✔ a dropped batch outranks a load error outranks the queue (0.212696ms)
✔ dropTarget: before puts it ahead of the item, after behind it, null means first (0.943987ms)
✔ a drop that leaves the order as it is is no move (0.195184ms)
✔ dropOp is the move_slide (0.785553ms)
✔ labelFor is the slide's first heading, else the numbered fallback (7.559713ms)
✔ flush re-points every dirty thumbnail and relabels it (3.316514ms)
✔ destroy takes the item-menu listener off; a second mount leaves one (0.388333ms)
✔ plan says what to add, drop and where each item goes (1.387152ms)
✔ sync moves only the items out of place and keeps focus (2.038616ms)
✔ desktop view: box and size are the block; hidden and order are always the phone map (2.083699ms)
✔ phone view: everything goes to set_phone, carrying the rest of the map (0.626987ms)
✔ the ops it makes are ones the document takes (15.836752ms)
✔ phoneMap reads only the four keys, and only well-typed (0.223055ms)
✔ the stored view is read defensively (0.476387ms)
✔ linkAt reads the link under the cursor and nothing elsewhere (8.312174ms)
✔ setLink marks the selection (2.713201ms)
✔ setLink on a collapsed cursor inside a link replaces that link (5.981775ms)
✔ setLink with nothing selected and no link does nothing (3.465743ms)
✔ removeLink clears the link under the cursor (0.758332ms)
✔ the keymap binds every documented shortcut (0.283748ms)
✔ Mod-b toggles bold through the keymap (0.727554ms)
✔ setLink refuses an address outside the allowlist and writes nothing (0.871282ms)
✔ the state machine runs choose → sign → put → register → op (9.65863ms)
✔ a file the deck will not take never reaches the network (0.988641ms)
✔ alt text is required before the bytes move, unless the image is decorative (0.339031ms)
✔ a step that fails stops the ones after it and reports its own code (0.828943ms)
✔ a failure that is not ours still becomes an UploadError (0.791513ms)
✔ a second pick while one is in flight is refused, not raced (0.470176ms)
✔ an unreadable size is zero rather than a failure (1.125055ms)
✔ a per-call applyOp wins, so an upload lands on the block it was chosen for (0.308965ms)
✔ checkFile takes exactly the four types (1.164768ms)
✔ sha256Hex is the hex digest of the bytes (26.949901ms)
✔ refusal reads this app's JSON error shape, and survives one that is not (0.442865ms)
✔ malformed fonts are null (3.738158ms)
✔ a malformed background is the theme default (1.955ms)
✔ a malformed box is null (1.127789ms)
✔ the phone layer keeps only its typed fields, order as a whole number (1.024347ms)
✔ assets are typed or skipped (3.627373ms)
✔ every poison update is deterministic bytes that apply to any deck (10.647481ms)
ℹ tests 149
ℹ suites 0
ℹ pass 149
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 724.942535
=== the vectors must match a fresh generation
canvas: 22 ops, 3715 bytes
columns: 23 ops, 3134 bytes
every-block: 36 ops, 3180 bytes
table: 14 ops, 1509 bytes
title-only: 6 ops, 1136 bytes
=== the editor bundle must match a fresh build
wrote internal/slides/static/editor.ea69e9f399e840e0.js (375198 bytes)
=== go: vet, gofmt, test, migration check
go vet ./...
go: downloading amadan.net/oficina/estilo.git v0.0.0-20260924071126-894afdcb71a9
go: downloading amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9
go: downloading github.com/carlosframework/rastrillo v0.23.0
gofmt needed:
internal/slides/handlers.go
internal/slides/home.go
make: *** [Makefile:91: fmt-check] Error 1
amadan: step gate failed: exit status 2
amadan: job failed at step gate — its output is above, not at the tail of this log
diff --git a/cmd/slides/main.go b/cmd/slides/main.go| index a832ee4..31b10f7 100644 |
| --- a/cmd/slides/main.go |
| +++ b/cmd/slides/main.go |
| @@ -226,6 +226,12 @@ func main() { |
| var homeConfig *connect.Credentials |
| hc := connect.Credentials{Issuer: os.Getenv("SLIDES_HOME_ISSUER"), ConsumerID: os.Getenv("SLIDES_HOME_CONSUMER_ID"), KeysBase64: os.Getenv("SLIDES_HOME_KEYS_BASE64"), DEKBase64: os.Getenv("SLIDES_HOME_DEK_BASE64")} |
| if hc.Issuer != "" || hc.ConsumerID != "" || hc.KeysBase64 != "" || hc.DEKBase64 != "" { |
| + // The hub directory the app switcher's "All apps" links to. |
| + // Optional, and unprefixed: the same name and the same value |
| + // Home itself reads (connect.CredentialsFromEnv reads it the |
| + // same way; this app builds Credentials by hand). Checked by |
| + // connect.DecodeCredentials, so a malformed value refuses boot. |
| + hc.Directory = os.Getenv("HOME_DIRECTORY_ORIGIN") |
| homeConfig = &hc |
| } |
| mux, a, err := slides.New(d, slides.Config{Home: homeConfig, |
| @@ -257,9 +263,10 @@ func main() { |
| opts.Locales = slides.Locales |
| opts.DefaultLocale = slides.DefaultLocale |
| opts.LocaleFS = slides.LocaleFS |
| - // The same page a handler's own error path renders (render.go's |
| - // ErrorPage), so a panic and a handled failure look identical. |
| - opts.ErrorPage = slides.ErrorPage |
| + // The same page a handler's own error path renders (errors.go), so a |
| + // panic and a handled failure look identical, with the viewer's own |
| + // chrome. |
| + opts.ErrorPage = a.ErrorPage |
| if err := rastrillo.Serve(opts); err != nil { |
| logger.Error("serve failed", "err", err) |
| os.Exit(1) |
diff --git a/go.mod b/go.mod| index e97c061..4def82b 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -5,8 +5,8 @@ go 1.25.0 |
| toolchain go1.26.5 |
| require ( |
| - amadan.net/oficina/estilo.git v0.0.0-20260911182719-0bcb9287988d |
| - amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f |
| + amadan.net/oficina/estilo.git v0.0.0-20260924071126-894afdcb71a9 |
| + amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9 |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 |
| amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd |
| github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 |
| @@ -20,6 +20,7 @@ require ( |
| require ( |
| github.com/BurntSushi/toml v1.6.0 // indirect |
| + github.com/carlosframework/rastrillo v0.23.0 // indirect |
| github.com/chromedp/sysutil v1.1.0 // indirect |
| github.com/dustin/go-humanize v1.0.1 // indirect |
| github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect |
diff --git a/go.sum b/go.sum| index 5f799ff..b214e74 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,7 +1,7 @@ |
| -amadan.net/oficina/estilo.git v0.0.0-20260911182719-0bcb9287988d h1:3HsB96tGs+P0tGf2VzqpqA8yqYBhJ+apBh2txAoz9qI= |
| -amadan.net/oficina/estilo.git v0.0.0-20260911182719-0bcb9287988d/go.mod h1:0R45/vCUAT+kpS7S6K/ZxBdE8f7YxFN4HT3HyDJ3nPY= |
| -amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f h1:lkw3tjzHfZxe74pNfo+tGDz4mkZLg+D6R+FTkmsym6M= |
| -amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f/go.mod h1:YroavXe43Wietec+po8mtSk4a9Bir2h31Q3iAf4MWxI= |
| +amadan.net/oficina/estilo.git v0.0.0-20260924071126-894afdcb71a9 h1:vHOkWeJ7mRwLjPbMihf0f7aqz4CLSe0/NseQsHcJUYQ= |
| +amadan.net/oficina/estilo.git v0.0.0-20260924071126-894afdcb71a9/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| +amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9 h1:K/PaOVdQqRMwifPD6ipiwLeGR5nbFSxm9FxjrDJlZcg= |
| +amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9/go.mod h1:YroavXe43Wietec+po8mtSk4a9Bir2h31Q3iAf4MWxI= |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM= |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k= |
| amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd h1:jlFSt95M1ontOg+VePU+kIJpChtWqDQAbnmlxqCnbgc= |
diff --git a/internal/slides/agents.go b/internal/slides/agents.go| index 6ecd10d..337b48b 100644 |
| --- a/internal/slides/agents.go |
| +++ b/internal/slides/agents.go |
| @@ -402,7 +402,10 @@ func (a *app) renderAgentsPage(w http.ResponseWriter, r *http.Request, u User, n |
| } |
| page := agentsPage{ |
| - shell: shellFor(r, u), |
| + // Appearance returns to the bare path: this page's query can be |
| + // the one-time token (?new=), and reflecting a query into the |
| + // page would print it again (TestAgentTokenShownOnceAndHashed). |
| + shell: a.shellFor(r, u, frame{parts: []string{rastrillo.T(r, "slides.agents.title")}, ret: "/agents"}), |
| Title: rastrillo.T(r, "slides.agents.title"), |
| Lead: rastrillo.T(r, "slides.agents.lead"), |
| WarningTitle: rastrillo.T(r, "slides.agents.warning.title"), |
diff --git a/internal/slides/app.go b/internal/slides/app.go| index 54066f0..ae643e1 100644 |
| --- a/internal/slides/app.go |
| +++ b/internal/slides/app.go |
| @@ -4,6 +4,8 @@ import ( |
| "context" |
| "net/http" |
| + "amadan.net/oficina/estilo.git/brand" |
| + "amadan.net/oficina/estilo.git/chrome" |
| "amadan.net/oficina/home/connect" |
| "amadan.net/oficina/home/handoff" |
| "amadan.net/oficina/slides/internal/slides/core" |
| @@ -14,6 +16,7 @@ import ( |
| "amadan.net/rastrillo/rastrillo/db" |
| "amadan.net/rastrillo/rastrillo/jobs" |
| "amadan.net/rastrillo/rastrillo/migrate" |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| "github.com/go-chi/chi/v5" |
| ) |
| @@ -51,12 +54,22 @@ func New(d *db.DB, cfg Config) (*http.ServeMux, *app, error) { |
| // what this protects has to be the origin the app resolved. |
| if a.homeRoutes != nil { |
| r.Use(a.homeRoutes.Middleware) |
| - r.Use(a.homeMenu) |
| } else { |
| r.Use(csrf.Protect(a.cfg.Origin)) |
| } |
| + // A panic anywhere in the router is the 500 page with the viewer's |
| + // own chrome (errors.go); an unknown address is the 404 page, not |
| + // chi's plain text; a GET of a POST-only path is that 404 too. |
| + r.Use(a.recoverToErrorPage) |
| + r.NotFound(a.routeNotFound) |
| + r.MethodNotAllowed(a.methodNotAllowed(r)) |
| + |
| r.Get("/help/open-source", a.credits) |
| + // The account menu's Appearance (estilo chrome). Inside the app-wide |
| + // CSRF check above and outside the session group below: a session |
| + // that expired mid-page must not turn the click into a sign-in loop. |
| + r.Post("/appearance", chrome.HandleAppearance) |
| // Unauthenticated: the front door and rauth's four handlers. The |
| // sign-in page has to be reachable signed out or nobody could ever |
| @@ -66,10 +79,12 @@ func New(d *db.DB, cfg Config) (*http.ServeMux, *app, error) { |
| r.Post(signinPath, gateBegin(authPlugin.Begin, a.allowed, a.logger)) |
| r.Get("/auth/callback", authPlugin.Callback) |
| r.Get("/auth/verify", authPlugin.Verify) |
| - r.Post("/signout", authPlugin.Signout) |
| + r.Get(landedPath, a.landed) |
| + r.Post("/signout", signedOutAfter(authPlugin.Signout)) |
| + r.Get("/signed-out", a.signedOut) |
| } else { |
| h := a.homeRoutes |
| - r.Get(signinPath, h.Signin) |
| + r.Get(signinPath, a.homeSignin) |
| r.Get("/signin/local", a.signin) |
| r.Post(signinPath, h.LocalGuard(gateBegin(authPlugin.Begin, a.allowed, a.logger))) |
| r.Get("/auth/callback", h.LocalGuard(authPlugin.Callback)) |
| @@ -128,7 +143,7 @@ func New(d *db.DB, cfg Config) (*http.ServeMux, *app, error) { |
| // Everything else needs a real, signed-in person (SKILL.md §5). A |
| // signed-out GET/HEAD redirects to /signin; anything else 403s. |
| r.Group(func(r chi.Router) { |
| - r.Use(authPlugin.RequireSession) |
| + r.Use(a.requireSession(authPlugin.RequireSession)) |
| r.Get("/", a.decks) |
| // One screen, one job (SKILL.md §7): the list links to a page |
| // for creating, and that page is the only place a deck is made. |
| @@ -211,6 +226,9 @@ func New(d *db.DB, cfg Config) (*http.ServeMux, *app, error) { |
| // handler serves cacheable-forever. Mounted outside the chi router |
| // so the sign-in page can load its stylesheet signed out. |
| mux.HandleFunc("GET /static/credits.css", serveCreditsCSS) |
| + // The suite's app icons (the bar's brand, the switcher's tiles), |
| + // served from estilo rather than vendored. |
| + mux.Handle("GET "+brand.Prefix, brand.Handler()) |
| mux.Handle("GET /static/", assets.Handler()) |
| // The drive surface (api.go, spec §7), mounted HERE rather than in |
| // the chi router above, and this is the one deliberate exception in |
| @@ -308,6 +326,16 @@ func newApp(d *db.DB, cfg Config) (*app, *rauth.Auth, error) { |
| pdf: &pdfRuntime{}, |
| } |
| + // The optional session read behind every page outside the signed-in |
| + // group (chrome.go's optionalViewer). The same table and cookie as |
| + // rauth's own store. |
| + sess, err := sessions.New(sessions.Config{DB: d.Writer(), Origin: cfg.Origin, Logger: logger}) |
| + if err != nil { |
| + rt.Close(context.Background()) |
| + return nil, nil, err |
| + } |
| + a.sessions = sess |
| + |
| if err := a.initHome(homeConfig); err != nil { |
| rt.Close(context.Background()) |
| return nil, nil, err |
diff --git a/internal/slides/auth.go b/internal/slides/auth.go| index 8df7734..bef85a2 100644 |
| --- a/internal/slides/auth.go |
| +++ b/internal/slides/auth.go |
| @@ -28,8 +28,11 @@ import ( |
| "strings" |
| "time" |
| + "amadan.net/oficina/estilo.git/brand" |
| + "amadan.net/oficina/estilo.git/chrome" |
| "amadan.net/oficina/home/connect" |
| "amadan.net/oficina/home/connect/linkstore" |
| + "amadan.net/oficina/home/handoff" |
| "amadan.net/oficina/slides/internal/slides/present" |
| "amadan.net/rastrillo/rastrillo" |
| rauth "amadan.net/rastrillo/rastrillo/auth" |
| @@ -258,7 +261,7 @@ func (s *sameAnswer) WriteHeader(status int) { |
| // once, out loud, is what keeps the two from drifting silently if |
| // either default ever changes. |
| func newAuthPlugin(cfg Config, writer *sql.DB, list allowlist) (*rauth.Auth, error) { |
| - signedInPath := "/" |
| + signedInPath := landedPath |
| if cfg.Home != nil { |
| signedInPath = connect.LocalCompletePath |
| } |
| @@ -529,54 +532,285 @@ func (a *app) endPresentationsOf(r *http.Request, actor string) { |
| } |
| } |
| -// signinPage is GET /signin's view data. rauth's own handlers never |
| -// render a page (magic-links.md: "the sign-in page stays yours") — they |
| -// report every outcome by redirecting back here with a query this |
| -// handler reads. |
| -type signinPage struct { |
| - Title string |
| - Body string |
| - AddressLabel string |
| - SubmitLabel string |
| - Sent string // non-empty: a link was emailed — say so |
| - ErrorMessage string // non-empty: something went wrong — say what |
| +// signinView is the sign-in page's data: estilo's sign-in form, or, once |
| +// a link is on its way, the check-your-email state that replaces it. |
| +type signinView struct { |
| + shell |
| + SignIn chrome.SignIn |
| + Sent *chrome.SignInNotice |
| } |
| // signin serves GET /signin: the form, and the landing spot for every |
| // outcome rauth redirects back to (?sent=1, ?err=rate|address|expired| |
| -// keymail|1). rate, address and expired each get their own sentence; |
| -// keymail and a bare failure share the default, because every outcome |
| -// on this page means the same operational thing — sign-in did not |
| -// complete, ask for a new link — and rauth's Verify deliberately |
| -// refuses to be more specific than that, so there is no finer |
| -// distinction for this page to draw. |
| +// keymail|removed|1). rate, address, expired and removed each get their |
| +// own sentence; keymail and a bare failure share the default, because |
| +// every outcome on this page means the same operational thing — sign-in |
| +// did not complete, ask for a new link — and rauth's Verify deliberately |
| +// refuses to be more specific than that. |
| +// |
| +// Already signed in, it skips the form (spec §9.2): 303 to return_to or |
| +// the deck list. The admission recheck runs FIRST (currentUser), so an |
| +// address taken off the allowlist is signed out and told so here rather |
| +// than sent to a page that would send it straight back: the loop Astra |
| +// found in Docs (#65, finding 1) and Sheets (#39, finding 2). In Home |
| +// mode this handler is /signin/local, where a Home session goes on |
| +// purpose to link an account, so it never skips there. |
| +// |
| +// return_to (spec §9.4): rastrillo/auth lands a magic link on a fixed |
| +// SignedInPath and never reads return_to, so the value can't ride the |
| +// emailed link. It is remembered in a short-lived cookie here (returnCookie) |
| +// and read back at /auth/landed. |
| func (a *app) signin(w http.ResponseWriter, r *http.Request) { |
| q := r.URL.Query() |
| - data := signinPage{ |
| - Title: rastrillo.T(r, "slides.signin.title"), |
| - Body: rastrillo.T(r, "slides.signin.body"), |
| - AddressLabel: rastrillo.T(r, "slides.signin.address.label"), |
| - SubmitLabel: rastrillo.T(r, "slides.signin.submit"), |
| + ret, hasReturn := safeReturn(q) |
| + if a.home == nil { |
| + if s, ok := a.sessions.From(r); ok { |
| + _, outcome := a.currentUser(w, sessions.WithSession(r, s)) |
| + switch outcome { |
| + case sessionOK: |
| + dest := "/" |
| + if hasReturn { |
| + dest = ret |
| + } |
| + w.Header().Set("Cache-Control", "no-store") |
| + http.Redirect(w, r, dest, http.StatusSeeOther) |
| + return |
| + case sessionOffboarded: |
| + // currentUser has signed the address out and answered. |
| + return |
| + } |
| + } |
| + switch { |
| + case hasReturn: |
| + a.rememberReturn(w, ret) |
| + case len(q) == 0: |
| + // A plain /signin is a fresh start: a destination remembered |
| + // by an earlier, abandoned sign-in must not hijack this one. |
| + a.forgetReturn(w) |
| + } |
| } |
| + w.Header().Set("Cache-Control", "no-store") |
| if q.Get("sent") == "1" { |
| - data.Sent = rastrillo.T(r, "slides.signin.sent") |
| + // "Use a different address" goes back to the form keeping the |
| + // return (spec §9.2): the remembered page, not a bare /signin, |
| + // which would forget it (Astra on Docs #65, finding 2). |
| + again := signinPath |
| + if remembered, ok := a.rememberedReturn(r); ok { |
| + again += "?return_to=" + url.QueryEscape(remembered) |
| + } |
| + render(w, "signin", signinView{ |
| + shell: a.publicShell(r, frame{parts: []string{rastrillo.T(r, "slides.signin.sent.title")}, signinPage: true}), |
| + Sent: &chrome.SignInNotice{ |
| + Icon: brand.Path("slides"), |
| + Heading: rastrillo.T(r, "slides.signin.sent.title"), |
| + Body: rastrillo.T(r, "slides.signin.sent"), |
| + Link: &chrome.Link{Href: again, Label: rastrillo.T(r, "slides.signin.again")}, |
| + }, |
| + }) |
| + return |
| + } |
| + form := chrome.SignIn{ |
| + Icon: brand.Path("slides"), |
| + Heading: rastrillo.T(r, "slides.signin.title"), |
| + Lede: rastrillo.T(r, "slides.signin.lede"), |
| + Action: signinPath, |
| + Hidden: []chrome.Hidden{{Name: "force", Value: "1"}}, |
| + AddressLabel: rastrillo.T(r, "slides.signin.address.label"), |
| + Help: rastrillo.T(r, "slides.signin.help"), |
| + Submit: rastrillo.T(r, "slides.signin.submit"), |
| + OpenSource: &chrome.Link{Href: "/help/open-source", Label: rastrillo.T(r, "slides.credits.title")}, |
| + } |
| + if hasReturn { |
| + form.Return = ret |
| } |
| switch q.Get("err") { |
| case "": |
| // nothing to report |
| case "rate": |
| - data.ErrorMessage = rastrillo.T(r, "slides.signin.error.rate") |
| + form.Error = rastrillo.T(r, "slides.signin.error.rate") |
| case "address": |
| - data.ErrorMessage = rastrillo.T(r, "slides.signin.error.address") |
| + // An address error belongs to the field, not a banner. |
| + form.AddressError = rastrillo.T(r, "slides.signin.error.address") |
| case "expired": |
| - data.ErrorMessage = rastrillo.T(r, "slides.signin.error.expired") |
| + form.Error = rastrillo.T(r, "slides.signin.error.expired") |
| case "removed": |
| // Not "sign-in did not complete": this address had access and |
| // no longer does (offboard). Asking for another link will not |
| // help, and the sentence says so. |
| - data.ErrorMessage = rastrillo.T(r, "slides.signin.error.removed") |
| + form.Error = rastrillo.T(r, "slides.signin.error.removed") |
| default: |
| - data.ErrorMessage = rastrillo.T(r, "slides.signin.error.unknown") |
| + form.Error = rastrillo.T(r, "slides.signin.error.unknown") |
| + } |
| + render(w, "signin", signinView{ |
| + shell: a.publicShell(r, frame{parts: []string{form.Heading}, signinPage: true}), |
| + SignIn: form, |
| + }) |
| +} |
| + |
| +// homeSignin is GET /signin with Home configured: connect's own handler, |
| +// which remembers return_to for the Home exchange and, for a live |
| +// session, skips straight to the destination. Its skip reads the session |
| +// cookie and nothing else, so the admission recheck runs here first: a |
| +// Home user who has lost access is signed out and told so, rather than |
| +// bounced between /signin and a page that refuses them. |
| +func (a *app) homeSignin(w http.ResponseWriter, r *http.Request) { |
| + if s, ok := a.sessions.From(r); ok { |
| + if _, outcome := a.currentUser(w, sessions.WithSession(r, s)); outcome == sessionOffboarded { |
| + return |
| + } |
| + } |
| + a.homeRoutes.Signin(w, r) |
| +} |
| + |
| +// requireSession is rastrillo/auth's RequireSession with return_to |
| +// (spec §9.4): its refusal sends a page request to a bare /signin and |
| +// drops the path, so a signed-out visit to a deck link used to land on |
| +// the deck list after sign-in. A signed-out GET or HEAD is answered here |
| +// with 303 /signin?return_to=<the page>; everything else goes to the |
| +// guard as before (a POST still gets its 403). |
| +func (a *app) requireSession(guard func(http.Handler) http.Handler) func(http.Handler) http.Handler { |
| + return func(next http.Handler) http.Handler { |
| + guarded := guard(next) |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.Method == http.MethodGet || r.Method == http.MethodHead { |
| + if _, ok := a.sessions.From(r); !ok { |
| + w.Header().Set("Cache-Control", "no-store") |
| + http.Redirect(w, r, signinPath+"?return_to="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther) |
| + return |
| + } |
| + } |
| + guarded.ServeHTTP(w, r) |
| + }) |
| + } |
| +} |
| + |
| +// landedPath is rastrillo/auth's SignedInPath in standalone mode: where a |
| +// verified magic link lands, which then goes on to the remembered page. |
| +const landedPath = "/auth/landed" |
| + |
| +// landed serves GET /auth/landed: the remembered return_to, or the deck |
| +// list. The cookie is spent either way. |
| +func (a *app) landed(w http.ResponseWriter, r *http.Request) { |
| + dest := "/" |
| + if ret, ok := a.rememberedReturn(r); ok { |
| + dest = ret |
| + } |
| + a.forgetReturn(w) |
| + w.Header().Set("Cache-Control", "no-store") |
| + http.Redirect(w, r, dest, http.StatusSeeOther) |
| +} |
| + |
| +// returnCookieName is the remembered return_to. Host-only, and |
| +// __Host- prefixed wherever the origin is https. |
| +func (a *app) returnCookieName() string { |
| + if a.secure() { |
| + return "__Host-slides_return" |
| + } |
| + return "slides_return" |
| +} |
| + |
| +func (a *app) secure() bool { return strings.HasPrefix(a.cfg.Origin, "https://") } |
| + |
| +// returnCookieAge is how long a remembered page lasts: a link opened |
| +// after ten minutes, or in another browser, lands on the deck list. |
| +const returnCookieAge = 600 |
| + |
| +func (a *app) rememberReturn(w http.ResponseWriter, path string) { |
| + http.SetCookie(w, &http.Cookie{ |
| + Name: a.returnCookieName(), Value: url.QueryEscape(path), Path: "/", |
| + MaxAge: returnCookieAge, HttpOnly: true, Secure: a.secure(), |
| + SameSite: http.SameSiteLaxMode, |
| + }) |
| +} |
| + |
| +func (a *app) forgetReturn(w http.ResponseWriter) { |
| + http.SetCookie(w, &http.Cookie{ |
| + Name: a.returnCookieName(), Value: "", Path: "/", |
| + MaxAge: -1, HttpOnly: true, Secure: a.secure(), |
| + SameSite: http.SameSiteLaxMode, |
| + }) |
| +} |
| + |
| +// rememberedReturn reads the remembered page back, validated again: the |
| +// cookie is a request header like any other, and the value is only ever |
| +// a same-origin path (handoff.SafePath, which also refuses /signin and |
| +// /auth/*, so this can never loop). |
| +func (a *app) rememberedReturn(r *http.Request) (string, bool) { |
| + c, err := r.Cookie(a.returnCookieName()) |
| + if err != nil { |
| + return "", false |
| + } |
| + path, err := url.QueryUnescape(c.Value) |
| + if err != nil || !handoff.SafePath(path) { |
| + return "", false |
| + } |
| + return path, true |
| +} |
| + |
| +// safeReturn is the request's return_to, when there is exactly one and |
| +// it is a same-origin path (handoff.SafePath: no scheme, no host, no |
| +// "//", no control characters, not /signin or /auth/*). |
| +func safeReturn(q url.Values) (string, bool) { |
| + values, ok := q["return_to"] |
| + if !ok || len(values) != 1 || !handoff.SafePath(values[0]) { |
| + return "", false |
| + } |
| + return values[0], true |
| +} |
| + |
| +// signedOutAfter wraps rastrillo/auth's Signout, which ends the session |
| +// and redirects to the sign-in form: a page that, with no word about |
| +// what just happened, read like an error (polish audit, sign-on). The |
| +// redirect goes to /signed-out instead, which says so. |
| +func signedOutAfter(signout http.HandlerFunc) http.HandlerFunc { |
| + return func(w http.ResponseWriter, r *http.Request) { |
| + signout(&toSignedOut{ResponseWriter: w}, r) |
| + } |
| +} |
| + |
| +// toSignedOut rewrites Signout's Location, at the last moment a header is |
| +// still settable (sameAnswer's technique). |
| +type toSignedOut struct{ http.ResponseWriter } |
| + |
| +func (t *toSignedOut) WriteHeader(status int) { |
| + if t.Header().Get("Location") == signinPath { |
| + t.Header().Set("Location", "/signed-out") |
| + } |
| + t.ResponseWriter.WriteHeader(status) |
| +} |
| + |
| +// signedOut serves GET /signed-out in standalone mode. |
| +func (a *app) signedOut(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + a.renderSignedOut(w, r, http.StatusOK) |
| +} |
| + |
| +// signedOutView is the signed-out page's data. |
| +type signedOutView struct { |
| + shell |
| + Notice chrome.SignInNotice |
| +} |
| + |
| +// renderSignedOut is where Sign out lands, standalone and with Home |
| +// alike: "You're signed out", and a primary Sign in. |
| +func (a *app) renderSignedOut(w http.ResponseWriter, r *http.Request, status int) { |
| + title := rastrillo.T(r, "slides.signedout.title") |
| + var buf strings.Builder |
| + err := pages["signed_out"].ExecuteTemplate(&buf, "layout", signedOutView{ |
| + shell: a.publicShell(r, frame{parts: []string{title}, signinPage: true}), |
| + Notice: chrome.SignInNotice{ |
| + Icon: brand.Path("slides"), |
| + Heading: rastrillo.T(r, "slides.signedout.heading"), |
| + Body: rastrillo.T(r, "slides.signedout.body"), |
| + Link: &chrome.Link{Href: signinPath, Label: rastrillo.T(r, "slides.chrome.signin")}, |
| + }, |
| + }) |
| + if err != nil { |
| + a.logger.Error("slides: render signed-out page", "err", err) |
| + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) |
| + return |
| } |
| - render(w, "signin", data) |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + w.WriteHeader(status) |
| + _, _ = w.Write([]byte(buf.String())) |
| } |
diff --git a/internal/slides/chrome.go b/internal/slides/chrome.go| new file mode 100644 |
| index 0000000..e7cc6fa |
| --- /dev/null |
| +++ b/internal/slides/chrome.go |
| @@ -0,0 +1,258 @@ |
| +package slides |
| + |
| +// The shared chrome (estilo chrome, round 2): every page's bar, account |
| +// menu and <title> are built here, once, so no page can drift from |
| +// another. Before this the bar was rastrillo's topbar layout: 45px |
| +// signed out and 51px signed in (the Account summary was taller than the |
| +// bar and existed only once signed in), an account menu of an address, |
| +// Agents and Sign out, a separate one-item Help dropdown, and a <title> |
| +// that never named the app. |
| +// |
| +// Spec: estilo spec/shared-chrome, docs/superpowers/specs/ |
| +// 2026-09-23-shared-chrome.md, §5 (bar), §6 (switcher), §8 (account), |
| +// §10 (titles). Slides is the third adopter, after Docs and Sheets. |
| +// |
| +// Every label is a catalog key (AGENTS.md: a hardcoded English string is |
| +// a bug); estilo's templates carry no strings of their own. |
| + |
| +import ( |
| + "net/http" |
| + "net/url" |
| + |
| + "amadan.net/oficina/estilo.git/brand" |
| + "amadan.net/oficina/estilo.git/chrome" |
| + estilomenu "amadan.net/oficina/estilo.git/menu" |
| + "amadan.net/oficina/home/connect" |
| + "amadan.net/rastrillo/rastrillo" |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| +) |
| + |
| +// appName is the brand and the last part of every <title>. A proper |
| +// noun, the same in every locale, so not a catalog key. |
| +const appName = "Slides" |
| + |
| +// Frame is what layout.html reads from every page: the bar, the parts of |
| +// the <title> before the app's name, and the Appearance preference for |
| +// <html data-theme>. |
| +type Frame struct { |
| + Chrome chrome.Bar |
| + TitleParts []string |
| + Theme string |
| + // Bare pages have no bar at all: the presenting screens, which are |
| + // capability-addressed and shown on a projector, a lectern or a |
| + // phone in somebody's hand (spec §12.1, Slides). A signed-in |
| + // presenter used to see "Account → Sign in" there. |
| + Bare bool |
| +} |
| + |
| +// shell is embedded in every page's view data, so .Frame resolves on |
| +// every page the layout renders. |
| +type shell struct { |
| + Frame Frame |
| +} |
| + |
| +// frame describes one page's chrome. The zero value is a plain page whose |
| +// <title> is just "Slides". |
| +type frame struct { |
| + // parts precede "Slides" in <title>, most specific first. The first |
| + // is the page's h1 (spec §10). |
| + parts []string |
| + // context is the open deck's title, shown in the bar on the deck's |
| + // sub-pages. |
| + context string |
| + // public is a read-only page a stranger reaches (the share link): |
| + // the brand is plain text, and "Sign in" carries no return path, |
| + // because the path is a capability and must not be copied into |
| + // another URL. |
| + public bool |
| + // signinPage is one of the sign-in, sent and signed-out pages: the |
| + // corner is empty, and the brand is plain text (every page it could |
| + // link to is behind the session gate). |
| + signinPage bool |
| + // bare drops the bar (the presenting screens). |
| + bare bool |
| + // ret, when set, is where Appearance returns to instead of this |
| + // request's own path and query. |
| + ret string |
| +} |
| + |
| +// titled is the frame for a page whose <title> is these parts. |
| +func titled(parts ...string) frame { return frame{parts: parts} } |
| + |
| +// onDeck is the frame for one of a deck's sub-pages: the deck's title is |
| +// the bar's context and the second title part. |
| +func onDeck(page, deck string) frame { |
| + return frame{parts: []string{page, deck}, context: deck} |
| +} |
| + |
| +// viewerState is who the chrome is for: nobody, or a person and how |
| +// they signed in. |
| +type viewerState struct { |
| + user User |
| + method string |
| + ok bool |
| +} |
| + |
| +// optionalViewer resolves whoever is signed in on r without requiring |
| +// anybody, and without side effects: an error page, the Open source page |
| +// and the share-link viewer show the viewer's own chrome, but must never |
| +// sign somebody out or create a row. An address that is no longer |
| +// admitted is treated as signed out here; the next guarded page is where |
| +// currentUser ends that session. |
| +func (a *app) optionalViewer(r *http.Request) viewerState { |
| + s, ok := sessions.Current(r) |
| + if !ok && a.sessions != nil { |
| + s, ok = a.sessions.From(r) |
| + } |
| + if !ok || s.Subject == "" { |
| + return viewerState{} |
| + } |
| + if s.Method == connect.HomeMethod { |
| + u, err := a.homeUser(r, s) |
| + if err != nil { |
| + return viewerState{} |
| + } |
| + return viewerState{user: u, method: s.Method, ok: true} |
| + } |
| + if !a.allowed.allows(s.Subject) { |
| + return viewerState{} |
| + } |
| + var u User |
| + if err := a.db.G.WithContext(r.Context()).Where("email = ?", normalizeAddress(s.Subject)).Take(&u).Error; err != nil { |
| + return viewerState{} |
| + } |
| + return viewerState{user: u, method: s.Method, ok: true} |
| +} |
| + |
| +// shellFor is the shell of a page behind the session gate: u is the |
| +// person currentUser resolved. |
| +func (a *app) shellFor(r *http.Request, u User, f frame) shell { |
| + method := "" |
| + if s, ok := sessions.Current(r); ok { |
| + method = s.Method |
| + } |
| + return shell{Frame: a.frameFor(r, viewerState{user: u, method: method, ok: true}, f)} |
| +} |
| + |
| +// publicShell is the shell of a page reachable signed out: the viewer is |
| +// read optionally. |
| +func (a *app) publicShell(r *http.Request, f frame) shell { |
| + var v viewerState |
| + if !f.signinPage { |
| + v = a.optionalViewer(r) |
| + } |
| + return shell{Frame: a.frameFor(r, v, f)} |
| +} |
| + |
| +// frameFor builds a page's Frame for viewer v. |
| +func (a *app) frameFor(r *http.Request, v viewerState, f frame) Frame { |
| + bar := chrome.Bar{ |
| + App: chrome.App{ |
| + Name: appName, |
| + Slug: "slides", |
| + Home: "/", |
| + Icon: brand.Path("slides"), |
| + }, |
| + Context: f.context, |
| + Labels: chrome.BarLabels{Skip: rastrillo.T(r, "slides.chrome.skip"), Menu: rastrillo.T(r, "slides.chrome.menu")}, |
| + } |
| + switch { |
| + case f.signinPage: |
| + // The corner is empty on the pages that are themselves sign-in, |
| + // and the brand is plain text: everything it could link to is |
| + // behind the session gate and would bounce straight back here. |
| + bar.App.Home = "" |
| + case v.ok: |
| + bar.Account = a.account(r, v, f.ret) |
| + bar.Switcher = a.switcher(r) |
| + case f.public: |
| + bar.SignIn = &chrome.Link{Href: signinPath, Label: rastrillo.T(r, "slides.chrome.signin")} |
| + default: |
| + bar.SignIn = &chrome.Link{Href: signinPath + "?return_to=" + url.QueryEscape(returnTarget(r)), Label: rastrillo.T(r, "slides.chrome.signin")} |
| + } |
| + if f.public { |
| + bar.App.Home = "" |
| + } |
| + return Frame{ |
| + Chrome: bar, |
| + TitleParts: f.parts, |
| + Theme: chrome.ThemeAttr(chrome.Appearance(r)), |
| + Bare: f.bare, |
| + } |
| +} |
| + |
| +// returnTarget is where a person should come back to: this page, if it |
| +// is a page (a GET), otherwise the deck list. |
| +func returnTarget(r *http.Request) string { |
| + if r.Method != http.MethodGet && r.Method != http.MethodHead { |
| + return "/" |
| + } |
| + return r.URL.RequestURI() |
| +} |
| + |
| +// account is the account menu (spec §8.2): who is signed in, Agents and |
| +// "Connect your Oficina account" when that means something, Appearance, |
| +// Help (Open source, last), and the one Sign out in the app. |
| +func (a *app) account(r *http.Request, v viewerState, ret string) *chrome.Account { |
| + if ret == "" { |
| + ret = returnTarget(r) |
| + } |
| + u := v.user |
| + acc := &chrome.Account{ |
| + TriggerLabel: rastrillo.T(r, "slides.chrome.account"), |
| + Initials: chrome.Initials(u.Name, u.Email), |
| + Address: u.Email, |
| + Labels: chrome.AccountLabels{ |
| + SignedInAs: rastrillo.T(r, "slides.chrome.signed_in_as"), |
| + Appearance: rastrillo.T(r, "slides.chrome.appearance"), |
| + Help: rastrillo.T(r, "slides.credits.help"), |
| + }, |
| + Appearance: &chrome.AppearanceMenu{ |
| + Action: "/appearance", |
| + Return: ret, |
| + Options: chrome.AppearanceOptions(chrome.Appearance(r), |
| + rastrillo.T(r, "slides.chrome.appearance.light"), |
| + rastrillo.T(r, "slides.chrome.appearance.dark"), |
| + rastrillo.T(r, "slides.chrome.appearance.system")), |
| + }, |
| + Help: []estilomenu.Item{{Href: "/help/open-source", Label: rastrillo.T(r, "slides.credits.title")}}, |
| + SignOut: chrome.SignOut{Action: "/signout", Label: rastrillo.T(r, "slides.account.signout")}, |
| + } |
| + if u.Name != "" { |
| + acc.Name = u.Name |
| + } |
| + if u.Email != "" { |
| + acc.TriggerLabel = rastrillo.Tf(r, "slides.chrome.account.who", "address", u.Email) |
| + } |
| + // Agents (agents.go): the tokens that let a program drive this |
| + // person's decks. A page, so a link and no ellipsis. |
| + acc.Items = []estilomenu.Item{{Href: "/agents", Label: rastrillo.T(r, "slides.account.agents")}} |
| + if a.offersHomeLink(v) { |
| + acc.Items = append(acc.Items, estilomenu.Item{Href: "/account/home", Label: rastrillo.T(r, "slides.home.menu_action")}) |
| + } |
| + return acc |
| +} |
| + |
| +// offersHomeLink is whether the account menu offers to connect the |
| +// person's Oficina account: only when Home is configured, and never to |
| +// somebody who signed in through Home, whose account already is the |
| +// connection (spec §12.1 item 4; before this Slides offered it to Home |
| +// sessions too). |
| +func (a *app) offersHomeLink(v viewerState) bool { |
| + return a.home != nil && v.ok && v.method != connect.HomeMethod |
| +} |
| + |
| +// switcher is the app switcher, or nil when no hub directory is |
| +// configured. It shows "All apps" alone and no tiles (Paul's §13 answer |
| +// 2): a tile today still costs an assertion exchange through the |
| +// directory session. |
| +func (a *app) switcher(r *http.Request) *chrome.Switcher { |
| + if a.directory == "" { |
| + return nil |
| + } |
| + return &chrome.Switcher{ |
| + Label: rastrillo.T(r, "slides.chrome.switcher"), |
| + CurrentLabel: rastrillo.T(r, "slides.chrome.switcher.current"), |
| + AllApps: estilomenu.Item{Href: a.directory + "/", Label: rastrillo.T(r, "slides.chrome.switcher.all")}, |
| + } |
| +} |
diff --git a/internal/slides/credits.go b/internal/slides/credits.go| index aec33a9..5b51514 100644 |
| --- a/internal/slides/credits.go |
| +++ b/internal/slides/credits.go |
| @@ -1,11 +1,12 @@ |
| package slides |
| import ( |
| - "amadan.net/oficina/estilo.git/credits" |
| - "amadan.net/rastrillo/rastrillo" |
| _ "embed" |
| "encoding/json" |
| "net/http" |
| + |
| + "amadan.net/oficina/estilo.git/credits" |
| + "amadan.net/rastrillo/rastrillo" |
| ) |
| //go:embed credits/notices.json |
| @@ -25,16 +26,30 @@ var embeddedCredits = func() creditInventory { |
| }() |
| type creditsPage struct { |
| - Title, HelpLabel, CreditsLabel, SignInLabel, SignInHref string |
| - Credits credits.PageData |
| + shell |
| + Credits credits.PageData |
| } |
| +// credits serves GET /help/open-source, reachable signed out (the |
| +// sign-in page's footer links it) and signed in (the account menu's |
| +// Help). The bar shows whoever is signed in, so the account menu and its |
| +// one Sign out are here too. |
| +// |
| +// Referrer-Policy is strict-origin, not no-referrer (shared chrome spec |
| +// §9.5): the account menu's Sign out chains to id.*/signout, which |
| +// admits the hop by the Referer's origin, and a page that sends no |
| +// referrer at all turns that into id.*'s "Finish signing out" page. |
| +// strict-origin still sends no path or query to anyone, and each credit |
| +// link carries its own no-referrer. |
| func (a *app) credits(w http.ResponseWriter, r *http.Request) { |
| title := rastrillo.T(r, "slides.credits.title") |
| w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| w.Header().Set("Cache-Control", "no-store") |
| - w.Header().Set("Referrer-Policy", "no-referrer") |
| - render(w, "credits", creditsPage{Title: title, HelpLabel: rastrillo.T(r, "slides.credits.help"), CreditsLabel: title, SignInLabel: rastrillo.T(r, "slides.signin.title"), SignInHref: signinPath, Credits: credits.PageData{Title: title, MissingNoticeLabel: rastrillo.T(r, "slides.credits.missing"), Components: embeddedCredits.Components}}) |
| + w.Header().Set("Referrer-Policy", "strict-origin") |
| + render(w, "credits", creditsPage{ |
| + shell: a.publicShell(r, titled(title)), |
| + Credits: credits.PageData{Title: title, MissingNoticeLabel: rastrillo.T(r, "slides.credits.missing"), Components: embeddedCredits.Components}, |
| + }) |
| } |
| func serveCreditsCSS(w http.ResponseWriter, r *http.Request) { |
diff --git a/internal/slides/decks.go b/internal/slides/decks.go| index 4759c27..ce4e3e1 100644 |
| --- a/internal/slides/decks.go |
| +++ b/internal/slides/decks.go |
| @@ -478,14 +478,14 @@ func (a *app) viewer(w http.ResponseWriter, r *http.Request) (User, bool) { |
| func (a *app) fail(w http.ResponseWriter, r *http.Request, what string, err error) { |
| ref := rastrillo.NewRef() |
| a.logger.Error("slides: "+what, "err", err, "ref", ref) |
| - ErrorPage(w, r, http.StatusInternalServerError, ref) |
| + a.errorPage(w, r, http.StatusInternalServerError, kindStatus, ref) |
| } |
| // notFound is the answer for a deck that is not this person's to see — |
| // and for one that does not exist. Deliberately the same answer: see |
| // visibleDecks. |
| func (a *app) notFound(w http.ResponseWriter, r *http.Request) { |
| - ErrorPage(w, r, http.StatusNotFound, "") |
| + a.errorPage(w, r, http.StatusNotFound, kindDeck, "") |
| } |
| // deckFor resolves the {sqid} in r to a deck u may read, answering 404 |
| @@ -522,34 +522,6 @@ func (a *app) ownedDeckFor(w http.ResponseWriter, r *http.Request, u User) (Deck |
| // --- shared view data --- |
| -// shell is what every signed-in page carries for the layout's account |
| -// menu (templates/account.html). Embedded in each page's own view |
| -// struct, so the menu is one partial rather than a block redefined in |
| -// every template. |
| -type shell struct { |
| - HelpLabel string |
| - CreditsLabel string |
| - HomeLabel string |
| - Person string |
| - AgentsLabel string |
| - SignoutLabel string |
| -} |
| - |
| -func shellFor(r *http.Request, u User) shell { |
| - person := u.Name |
| - if person == "" { |
| - person = u.Email |
| - } |
| - return shell{ |
| - HelpLabel: rastrillo.T(r, "slides.credits.help"), |
| - CreditsLabel: rastrillo.T(r, "slides.credits.title"), |
| - Person: person, |
| - HomeLabel: homeMenuLabel(r), |
| - AgentsLabel: rastrillo.T(r, "slides.account.agents"), |
| - SignoutLabel: rastrillo.T(r, "slides.account.signout"), |
| - } |
| -} |
| - |
| // selectOption is one entry of ui's field-select Options. |
| type selectOption struct { |
| Value string |
| @@ -679,7 +651,8 @@ func (a *app) decks(w http.ResponseWriter, r *http.Request) { |
| } |
| page := decksPage{ |
| - shell: shellFor(r, u), |
| + // The app root's <title> is the app's name alone (spec §10). |
| + shell: a.shellFor(r, u, frame{}), |
| Title: rastrillo.T(r, "slides.decks.title"), |
| NewHref: "/decks/new", |
| NewLabel: rastrillo.T(r, "slides.decks.new"), |
| @@ -833,7 +806,7 @@ func (a *app) newDeck(w http.ResponseWriter, r *http.Request) { |
| func (a *app) newDeckPage(r *http.Request, u User, title, theme string, errs map[string]string) deckNewPage { |
| return deckNewPage{ |
| - shell: shellFor(r, u), |
| + shell: a.shellFor(r, u, titled(rastrillo.T(r, "slides.decks.new"))), |
| Title: rastrillo.T(r, "slides.decks.new"), |
| Body: rastrillo.T(r, "slides.decks.new.body"), |
| TitleLabel: rastrillo.T(r, "slides.deck.title.label"), |
| @@ -937,7 +910,7 @@ type deckSettingsPage struct { |
| func (a *app) settingsPage(r *http.Request, u User, d Deck, title, theme, rung string, errs map[string]string) deckSettingsPage { |
| base := "/d/" + d.Sqid |
| return deckSettingsPage{ |
| - shell: shellFor(r, u), |
| + shell: a.shellFor(r, u, onDeck(rastrillo.T(r, "slides.deck.settings"), d.Title)), |
| Title: rastrillo.T(r, "slides.deck.settings"), |
| Action: base + "/settings", |
| TitleLabel: rastrillo.T(r, "slides.deck.title.label"), |
| @@ -1050,7 +1023,7 @@ func (a *app) confirmDeleteDeck(w http.ResponseWriter, r *http.Request) { |
| } |
| base := "/d/" + d.Sqid |
| render(w, "deck_delete", deckDeletePage{ |
| - shell: shellFor(r, u), |
| + shell: a.shellFor(r, u, frame{parts: []string{rastrillo.Tf(r, "slides.deck.delete.title", "title", d.Title)}, context: d.Title}), |
| Title: rastrillo.Tf(r, "slides.deck.delete.title", "title", d.Title), |
| Body: rastrillo.T(r, "slides.deck.delete.body"), |
| Action: base + "/delete", |
diff --git a/internal/slides/editor.go b/internal/slides/editor.go| index d71c4e9..190562a 100644 |
| --- a/internal/slides/editor.go |
| +++ b/internal/slides/editor.go |
| @@ -190,13 +190,16 @@ func (a *app) deckEditor(w http.ResponseWriter, r *http.Request) { |
| defer h.Close(r.Context()) |
| base := "/d/" + d.Sqid |
| - sh := shellFor(r, u) |
| + person := u.Name |
| + if person == "" { |
| + person = u.Email |
| + } |
| page := editorPage{ |
| - shell: sh, |
| + shell: a.shellFor(r, u, titled(view.Meta.Title)), |
| Title: view.Meta.Title, |
| DeckSqid: d.Sqid, |
| Seq: d.Seq, |
| - ClientName: sh.Person, |
| + ClientName: person, |
| ClientColour: u.Colour, |
| BundleSrc: editorBundlePath(), |
diff --git a/internal/slides/errors.go b/internal/slides/errors.go| new file mode 100644 |
| index 0000000..e87cd64 |
| --- /dev/null |
| +++ b/internal/slides/errors.go |
| @@ -0,0 +1,246 @@ |
| +package slides |
| + |
| +// HTML error pages (shared chrome spec §3). Before this an unknown URL |
| +// answered chi's plain-text "404 page not found" in monospace, with no |
| +// chrome and an empty <title> (polish audit, "prototype"), and the |
| +// handled error pages always showed signed-out chrome, whoever was |
| +// signed in. |
| +// |
| +// Which answers are pages is decided by the request, not the route: a |
| +// page is rendered only when Accept names text/html, as every browser |
| +// navigation and form post does. The editor's fetches (slide HTML, |
| +// updates, presence), the SSE streams and every <img> of a thumbnail or |
| +// an asset never ask for HTML, so they keep plain answers. |
| +// |
| +// A deck that is missing and one that is not shared with you are the |
| +// same 404 (visibleDecks): nothing here distinguishes them. |
| + |
| +import ( |
| + "fmt" |
| + "net/http" |
| + "net/url" |
| + "runtime/debug" |
| + "strings" |
| + |
| + "amadan.net/oficina/estilo.git/chrome" |
| + "amadan.net/rastrillo/rastrillo" |
| + "github.com/go-chi/chi/v5" |
| +) |
| + |
| +// errorKind says which row of spec §3.2 a failure is. |
| +type errorKind int |
| + |
| +const ( |
| + // kindRoute: nothing lives at this address. |
| + kindRoute errorKind = iota |
| + // kindDeck: the deck (or share link, or talk) is missing, or not |
| + // shared with you. |
| + kindDeck |
| + // kindStatus: the page for the status itself (403, 410, 422, 5xx). |
| + kindStatus |
| +) |
| + |
| +// errorWords is one row of §3.2, resolved from the catalog: the title |
| +// part, the heading and the body. |
| +type errorWords struct { |
| + title, heading, body string |
| +} |
| + |
| +func errorCopy(r *http.Request, status int, kind errorKind) errorWords { |
| + key := "slides.error.500" |
| + switch { |
| + case status == http.StatusNotFound && kind == kindDeck: |
| + key = "slides.error.deck" |
| + case status == http.StatusNotFound: |
| + key = "slides.error.404" |
| + case status == http.StatusForbidden: |
| + key = "slides.error.403" |
| + case status == http.StatusGone: |
| + // A talk that finished (presentation.go's gone): its own words, |
| + // because it is a different fact from a mistyped address. |
| + key = "slides.present.over" |
| + case status == http.StatusUnprocessableEntity: |
| + key = "slides.error.422" |
| + case status == http.StatusServiceUnavailable: |
| + key = "slides.error.503" |
| + } |
| + return errorWords{ |
| + title: rastrillo.T(r, key+".title"), |
| + heading: rastrillo.T(r, key+".heading"), |
| + body: rastrillo.T(r, key+".body"), |
| + } |
| +} |
| + |
| +// errorView is templates/errors.html's data. |
| +type errorView struct { |
| + shell |
| + Error chrome.ErrorPage |
| +} |
| + |
| +// wantsHTML reports whether the request asked for a page. |
| +func wantsHTML(r *http.Request) bool { |
| + return strings.Contains(r.Header.Get("Accept"), "text/html") |
| +} |
| + |
| +// plainError is the answer to a request that did not ask for a page. |
| +func plainError(w http.ResponseWriter, status int) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + http.Error(w, http.StatusText(status), status) |
| +} |
| + |
| +// routePattern is the matched route, "/d/{sqid}" rather than the path it |
| +// matched: safe to log whatever the path held (a /s/ or /p/ path is a |
| +// capability). |
| +func routePattern(r *http.Request) string { |
| + if rc := chi.RouteContext(r.Context()); rc != nil && rc.RoutePattern() != "" { |
| + return rc.RoutePattern() |
| + } |
| + return "(unrouted)" |
| +} |
| + |
| +// errorPage answers status: a page inside the normal layout with the |
| +// viewer's own chrome for a browser, plain text otherwise. ref is the |
| +// reference a 500 quotes, empty otherwise. |
| +func (a *app) errorPage(w http.ResponseWriter, r *http.Request, status int, kind errorKind, ref string) { |
| + if !wantsHTML(r) { |
| + plainError(w, status) |
| + return |
| + } |
| + writeErrorPage(w, r, status, kind, ref, a.cfg.Origin, a.publicShell(r, titled(errorCopy(r, status, kind).title))) |
| +} |
| + |
| +// writeErrorPage renders the page with sh as its chrome. |
| +func writeErrorPage(w http.ResponseWriter, r *http.Request, status int, kind errorKind, ref, origin string, sh shell) { |
| + c := errorCopy(r, status, kind) |
| + page := chrome.ErrorPage{ |
| + Status: status, |
| + Title: c.heading, |
| + Body: c.body, |
| + Home: chrome.Link{Href: "/", Label: rastrillo.T(r, "slides.error.home")}, |
| + } |
| + // Try again is the same URL, and only for a GET: re-sending a POST |
| + // from an error page is the one thing it must never do. |
| + if status >= 500 && r.Method == http.MethodGet { |
| + page.Home = chrome.Link{Href: r.URL.RequestURI(), Label: rastrillo.T(r, "slides.error.again")} |
| + } |
| + if ref != "" { |
| + page.Ref, page.RefLabel = ref, rastrillo.T(r, "slides.error.ref") |
| + } |
| + if back := sameOriginReferer(r, origin); back != "" && status < 500 { |
| + page.Back = &chrome.Link{Href: back, Label: rastrillo.T(r, "slides.error.back")} |
| + } |
| + var buf strings.Builder |
| + if err := pages["errors"].ExecuteTemplate(&buf, "layout", errorView{shell: sh, Error: page}); err != nil { |
| + // The page could not render: say so in the plainest way left. |
| + plainError(w, status) |
| + return |
| + } |
| + w.Header().Set("Cache-Control", "no-store") |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + w.WriteHeader(status) |
| + _, _ = w.Write([]byte(buf.String())) |
| +} |
| + |
| +// sameOriginReferer is the page a person came from, when it is this app |
| +// and not this page: rastrillo's rule for the error page's Back link. An |
| +// unchecked Referer is an open redirect with better manners. origin is |
| +// the app's own (Config.Origin); without one there is no Back link. |
| +func sameOriginReferer(r *http.Request, origin string) string { |
| + raw := r.Header.Get("Referer") |
| + if raw == "" || origin == "" { |
| + return "" |
| + } |
| + u, err := url.Parse(raw) |
| + if err != nil || u.User != nil || u.Scheme+"://"+u.Host != origin { |
| + return "" |
| + } |
| + back := u.EscapedPath() |
| + if u.RawQuery != "" { |
| + back += "?" + u.RawQuery |
| + } |
| + // Never a capability path (a share link, a talk, a phone's pairing |
| + // code): the Back link would copy it into this page. |
| + for _, p := range []string{"/s/", "/p/", "/r/"} { |
| + if strings.HasPrefix(back, p) { |
| + return "" |
| + } |
| + } |
| + if !strings.HasPrefix(back, "/") || strings.HasPrefix(back, "//") || back == r.URL.RequestURI() { |
| + return "" |
| + } |
| + return back |
| +} |
| + |
| +// routeNotFound is the router's NotFound: an unknown address. |
| +func (a *app) routeNotFound(w http.ResponseWriter, r *http.Request) { |
| + a.errorPage(w, r, http.StatusNotFound, kindRoute, "") |
| +} |
| + |
| +// methodNotAllowed is the router's MethodNotAllowed. A GET or HEAD to a |
| +// path that only takes a POST (somebody opening /signout from history) |
| +// is the same 404 page as an unknown route: to a person there is no |
| +// page there. Any other method gets 405 in plain text, with the Allow |
| +// header set here — chi's default sets it, a custom handler does not. |
| +func (a *app) methodNotAllowed(routes chi.Routes) http.HandlerFunc { |
| + return func(w http.ResponseWriter, r *http.Request) { |
| + var allowed []string |
| + for _, m := range []string{http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} { |
| + if routes.Match(chi.NewRouteContext(), m, r.URL.Path) { |
| + allowed = append(allowed, m) |
| + } |
| + } |
| + get := false |
| + for _, m := range allowed { |
| + get = get || m == http.MethodGet |
| + } |
| + if (r.Method == http.MethodGet || r.Method == http.MethodHead) && !get { |
| + a.routeNotFound(w, r) |
| + return |
| + } |
| + w.Header().Set("Allow", strings.Join(allowed, ", ")) |
| + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) |
| + } |
| +} |
| + |
| +// recoverToErrorPage turns a panic in a handler into the 500 page with a |
| +// reference, logged with the stack under the same reference. The page |
| +// reads the session itself (publicShell), so it shows the viewer's own |
| +// chrome wherever in the router the panic happened. Panics outside the |
| +// router reach rastrillo.Serve's own recoverer through a.ErrorPage. |
| +func (a *app) recoverToErrorPage(next http.Handler) http.Handler { |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + defer func() { |
| + v := recover() |
| + if v == nil { |
| + return |
| + } |
| + if v == http.ErrAbortHandler { |
| + panic(v) |
| + } |
| + ref := rastrillo.NewRef() |
| + // The value is logged as its type only: a panic raised with a |
| + // deck's text in it would be a bug of its own. |
| + a.logger.Error("slides: panic", "ref", ref, "route", routePattern(r), "panic", fmt.Sprintf("%T", v), "stack", string(debug.Stack())) |
| + a.errorPage(w, r, http.StatusInternalServerError, kindStatus, ref) |
| + }() |
| + next.ServeHTTP(w, r) |
| + }) |
| +} |
| + |
| +// ErrorPage is rastrillo.Options.ErrorPage for a built app: the page |
| +// rastrillo.Serve's own panic recovery renders, for a panic outside the |
| +// router. It reads the session optionally like every other error page. |
| +func (a *app) ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) { |
| + a.errorPage(w, r, status, kindStatus, ref) |
| +} |
| + |
| +// ErrorPage renders the error page with signed-out chrome, for a caller |
| +// that has no app to resolve a viewer with. |
| +func ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) { |
| + if !wantsHTML(r) { |
| + plainError(w, status) |
| + return |
| + } |
| + sh := shell{Frame: (&app{}).frameFor(r, viewerState{}, titled(errorCopy(r, status, kindStatus).title))} |
| + writeErrorPage(w, r, status, kindStatus, ref, "", sh) |
| +} |
diff --git a/internal/slides/exports.go b/internal/slides/exports.go| index 472fb06..01b9cbe 100644 |
| --- a/internal/slides/exports.go |
| +++ b/internal/slides/exports.go |
| @@ -379,7 +379,7 @@ func (a *app) exportForm(w http.ResponseWriter, r *http.Request) { |
| notice = f.Message |
| } |
| render(w, "deck_export", exportPage{ |
| - shell: shellFor(r, u), |
| + shell: a.shellFor(r, u, onDeck(rastrillo.T(r, "slides.export.title"), d.Title)), |
| Title: rastrillo.T(r, "slides.export.title"), |
| DeckTitle: d.Title, |
| Body: rastrillo.T(r, "slides.export.body"), |
| @@ -899,7 +899,7 @@ func (a *app) renderExportPage(w http.ResponseWriter, r *http.Request, d jobs.Pa |
| return |
| } |
| page := a.exportStatusFor(r, d) |
| - page.shell = shellFor(r, u) |
| + page.shell = a.shellFor(r, u, titled(page.Title)) |
| render(w, "export_status", page) |
| } |
diff --git a/internal/slides/handlers.go b/internal/slides/handlers.go| index 96698d5..b0b3bce 100644 |
| --- a/internal/slides/handlers.go |
| +++ b/internal/slides/handlers.go |
| @@ -27,6 +27,15 @@ type app struct { |
| home *connect.Bridge |
| homeRoutes *connect.Routes |
| homeSessions *sessions.Sessions |
| + // sessions reads the session cookie on a page outside the signed-in |
| + // group (an error page, Open source, the share-link viewer), so the |
| + // bar shows who is signed in there too. The same table and cookie |
| + // rauth's own store uses; never used to sign anybody in. |
| + sessions *sessions.Sessions |
| + // directory is the hub directory (HOME_DIRECTORY_ORIGIN), set only |
| + // with Home configured: the app switcher's "All apps" link. Empty, |
| + // no switcher. |
| + directory string |
| db *db.DB |
| logger *slog.Logger |
| cfg Config |
diff --git a/internal/slides/home.go b/internal/slides/home.go| index ee8728d..9e4ef28 100644 |
| --- a/internal/slides/home.go |
| +++ b/internal/slides/home.go |
| @@ -75,15 +75,36 @@ func (a *app) initHome(c *connect.DecodedCredentials) error { |
| return err |
| } |
| a.home = b |
| + a.directory = c.Directory |
| a.homeRoutes, err = connect.NewRoutes(b, connect.RoutesConfig{Namespace: "oficina/slides", Origin: a.cfg.Origin, Issuer: c.Issuer, DEK: c.DEK, SessionCookie: sess.CookieName(), ReadSession: read, SignOut: sess.SignOut, LocalSignin: a.signin, Protect: csrf.Protect(a.cfg.Origin), Render: a.renderHome, FormPolicy: connect.FormPolicy(c.Issuer)}) |
| return err |
| } |
| +// homeLinkView is the account-connection pages' data: connect's page |
| +// under this app's own bar. |
| +type homeLinkView struct { |
| + shell |
| + connect.PageData |
| +} |
| + |
| +// renderHome renders connect's account-connection pages, and the |
| +// signed-out page Sign out lands on, in this app's layout and bar. |
| +// |
| +// Referrer-Policy is strict-origin, not no-referrer (shared chrome spec |
| +// §9.5): these pages show the account menu once somebody is signed in, |
| +// and its Sign out chains to id.*/signout, which admits the hop by the |
| +// Referer's origin. strict-origin still sends no path or query. |
| func (a *app) renderHome(w http.ResponseWriter, r *http.Request, state, label string, status int) { |
| w.Header().Set("Cache-Control", "no-store") |
| - w.Header().Set("Referrer-Policy", "no-referrer") |
| + w.Header().Set("Referrer-Policy", "strict-origin") |
| + if state == "signedout" { |
| + // The same signed-out page standalone mode shows (auth.go), so |
| + // Sign out lands on one page whichever way the person signed in. |
| + a.renderSignedOut(w, r, status) |
| + return |
| + } |
| page := connect.Page(state, label, func(key string) string { return rastrillo.T(r, "slides.home."+key) }) |
| var buf bytes.Buffer |
| - if err := pages["home_link"].ExecuteTemplate(&buf, "layout", page); err != nil { |
| + if err := pages["home_link"].ExecuteTemplate(&buf, "layout", homeLinkView{shell: a.publicShell(r, titled(page.Title)), PageData: page}); err != nil { |
| a.logger.Error("slides: render Home account page", "err", err) |
| http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) |
| return |
| @@ -113,20 +134,6 @@ func (a *app) homeUser(r *http.Request, s sessions.Session) (User, error) { |
| return u, nil |
| } |
| -type homeMenuKey struct{} |
| - |
| -func homeMenuLabel(r *http.Request) string { |
| - if enabled, _ := r.Context().Value(homeMenuKey{}).(bool); enabled { |
| - return rastrillo.T(r, "slides.home.menu_action") |
| - } |
| - return "" |
| -} |
| -func (a *app) homeMenu(next http.Handler) http.Handler { |
| - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| - next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), homeMenuKey{}, true))) |
| - }) |
| -} |
| - |
| func (a *app) revokeHome(w http.ResponseWriter, r *http.Request, subject string) { |
| var u User |
| if a.home != nil && a.db.G.WithContext(r.Context()).Raw("SELECT u.* FROM users u JOIN home_identity_links l ON l.person_id=u.id WHERE l.issuer=? AND l.subject=?", a.cfg.Home.Issuer, subject).Scan(&u).Error == nil && u.ID != 0 && !a.allowed.allows(u.Email) { |
diff --git a/internal/slides/locales/en.toml b/internal/slides/locales/en.toml| index a45813e..da32d6e 100644 |
| --- a/internal/slides/locales/en.toml |
| +++ b/internal/slides/locales/en.toml |
| @@ -7,26 +7,77 @@ |
| # Adding a language means adding a file beside this one with the same |
| # keys; nothing else in the app has to change. |
| -# slides.signin.* — GET /signin (auth.go's signin handler). The page |
| -# renders the form and, on the way back from rastrillo/auth, whichever |
| -# outcome it was redirected here with (?sent=1, or |
| -# ?err=rate|address|expired|keymail|1). |
| +# slides.signin.* — GET /signin (auth.go's signin handler), estilo's |
| +# sign-in pages with the suite's copy deck (shared chrome spec §9.2). The |
| +# page renders the form and, on the way back from rastrillo/auth, |
| +# whichever outcome it was redirected here with (?sent=1, or |
| +# ?err=rate|address|expired|keymail|removed|1). |
| # |
| # error.unknown covers both the keymail outcome and a bare failure: |
| # rastrillo/auth's Verify deliberately answers the same way for an |
| # unknown, a used and an expired token, so there is no finer |
| # distinction for this page to draw — and every outcome on this screen |
| # means one operational thing, "ask for a new link". |
| +# |
| +# sent says "that address", not the address: gateBegin answers every |
| +# POST with the same bare ?sent=1, so the page has no address to echo. |
| slides.signin.title = "Sign in" |
| -slides.signin.body = "Enter your email address and we'll send you a link to sign in." |
| slides.signin.address.label = "Email address" |
| -slides.signin.submit = "Send link" |
| -slides.signin.sent = "Check your email for a link to sign in." |
| -slides.signin.error.rate = "Too many attempts. Wait a few minutes and try again." |
| -slides.signin.error.address = "That doesn't look like a valid email address." |
| -slides.signin.error.expired = "That link has expired or was already used. Enter your email below for a new one." |
| -slides.signin.error.unknown = "We couldn't sign you in. Enter your email below for a new link." |
| -slides.signin.error.removed = "Your access to this instance has been removed, so you've been signed out. A new link won't help — ask whoever runs it if this is wrong." |
| +slides.signin.help = "We’ll email you a link that signs you in. It works once and expires in 15 minutes." |
| +slides.signin.submit = "Email me a link" |
| +slides.signin.sent.title = "Check your email" |
| +slides.signin.sent = "If that address can sign in here, we’ve sent it a link. It works once and expires in 15 minutes." |
| +slides.signin.again = "Use a different address" |
| +slides.signin.error.rate = "Too many attempts. Wait a few minutes, then try again." |
| +slides.signin.error.address = "That doesn’t look like an email address." |
| +slides.signin.error.expired = "That link has expired or was already used. Enter your email to get a new one." |
| +slides.signin.error.unknown = "We couldn’t sign you in. Enter your email to get a new link." |
| +slides.signin.error.removed = "Your access has been removed, so you’ve been signed out. Ask whoever runs this team if that’s wrong." |
| + |
| +# slides.signedout.* — where Sign out lands, standalone and with Home. |
| +slides.signedout.title = "Signed out" |
| +slides.signedout.heading = "You’re signed out" |
| +slides.signedout.body = "You’ve signed out of Slides on this device." |
| + |
| +# slides.chrome.* — estilo's shared bar and account menu (chrome.go). |
| +slides.chrome.skip = "Skip to content" |
| +slides.chrome.menu = "Menu" |
| +slides.chrome.signin = "Sign in" |
| +slides.chrome.account = "Account" |
| +slides.chrome.account.who = "Account: {address}" |
| +slides.chrome.signed_in_as = "Signed in as" |
| +slides.chrome.appearance = "Appearance" |
| +slides.chrome.appearance.light = "Light" |
| +slides.chrome.appearance.dark = "Dark" |
| +slides.chrome.appearance.system = "System" |
| +slides.chrome.switcher = "Oficina apps" |
| +slides.chrome.switcher.current = "(this app)" |
| +slides.chrome.switcher.all = "All apps" |
| + |
| +# slides.error.* — the error page (errors.go), shared chrome spec §3.2. |
| +# title is the <title>'s first part; heading the page's h1. |
| +slides.error.404.title = "Page not found" |
| +slides.error.404.heading = "We can’t find that page" |
| +slides.error.404.body = "The link may be out of date, or the page may have moved." |
| +slides.error.deck.title = "Deck not available" |
| +slides.error.deck.heading = "This deck isn’t available" |
| +slides.error.deck.body = "It may have been deleted, or it hasn’t been shared with you." |
| +slides.error.403.title = "No access" |
| +slides.error.403.heading = "You don’t have access to this page" |
| +slides.error.403.body = "Ask whoever runs this team if you think you should." |
| +slides.error.422.title = "That didn’t work" |
| +slides.error.422.heading = "That didn’t work" |
| +slides.error.422.body = "Go back and try again." |
| +slides.error.500.title = "Something went wrong" |
| +slides.error.500.heading = "Something went wrong" |
| +slides.error.500.body = "Try again in a moment. If it keeps happening, quote the reference below." |
| +slides.error.503.title = "Slides is unavailable" |
| +slides.error.503.heading = "Slides is unavailable right now" |
| +slides.error.503.body = "Try again in a few minutes." |
| +slides.error.home = "Go to Slides" |
| +slides.error.again = "Try again" |
| +slides.error.ref = "Reference:" |
| +slides.error.back = "Back" |
| # slides.decks.* — GET /, the deck list, and GET /decks/new. Title is |
| # the screen's noun, per page-header's own contract ("Decks", not |
| @@ -183,7 +234,8 @@ slides.viewer.export = "Export PDF" |
| # document was emptied by something other than a screen, which is |
| # exactly when a page saying "not found" would read as data loss. |
| slides.viewer.empty.title = "No slides yet" |
| -slides.viewer.empty.body = "This deck has no slides yet. An agent or the API adds them; see docs/api.md." |
| +slides.viewer.empty.body = "This deck has no slides yet. Open the editor to add one." |
| +slides.viewer.empty.body.link = "This deck has no slides yet." |
| # slides.editor.* — GET /d/{sqid}/edit, the editor (editor.go). The |
| # status and link strings are rendered into data attributes on the page |
| @@ -456,7 +508,8 @@ slides.present.elapsed = "Running {n} min" |
| # A session that is over — ended, or past its twelve hours. A 410, and |
| # worded as one: the talk finished, which is a different thing from an |
| # address that was never real. |
| -slides.present.over.title = "That presentation is over" |
| +slides.present.over.title = "Presentation over" |
| +slides.present.over.heading = "That presentation is over" |
| slides.present.over.body = "The talk it was showing has ended. Ask whoever presented it for the deck, or open it again from your own list." |
| # Pairing a phone. Shown once, at the moment of pairing, because only |
| @@ -548,43 +601,28 @@ slides.export.error.render = "The PDF couldn't be made. Try again, and tell us i |
| slides.export.error.toobig = "This PDF is larger than we can hold. Export it without the notes, or split the deck." |
| slides.export.error.store = "The PDF was made but couldn't be stored. Try again." |
| +# slides.home.* — connect's account-connection pages. The hub is never |
| +# named: it is "your Oficina account", and the verb is Connect (Paul's |
| +# answer 3 to the shared chrome spec's §13). |
| slides.home.signedout_title = "Signed out" |
| - |
| -slides.home.signedout_body = "You can sign in again with Home." |
| - |
| -slides.home.signedout_action = "Sign in with Home" |
| - |
| -slides.home.start_title = "Connect your account to Home" |
| - |
| -slides.home.start_body = "Use Home to sign in to this Slides account. Your content and sharing settings stay as they are." |
| - |
| -slides.home.start_action = "Continue to Home" |
| - |
| +slides.home.signedout_body = "You’ve signed out of Slides on this device." |
| +slides.home.signedout_action = "Sign in" |
| +slides.home.start_title = "Connect your Oficina account" |
| +slides.home.start_body = "Use your Oficina account to sign in to this Slides account. Your decks and sharing settings stay as they are." |
| +slides.home.start_action = "Continue" |
| slides.home.cancel_action = "Back" |
| - |
| slides.home.confirm_title = "Confirm account connection" |
| - |
| -slides.home.confirm_body = "Connect the account below to the Home account you just signed into." |
| - |
| +slides.home.confirm_body = "Connect the account below to the Oficina account you just signed in to." |
| slides.home.confirm_action = "Connect accounts" |
| - |
| -slides.home.unknown_title = "Home account not connected" |
| - |
| -slides.home.unknown_body = "Sign in to your existing Slides account, then connect Home from the account menu." |
| - |
| +slides.home.unknown_title = "Oficina account not connected" |
| +slides.home.unknown_body = "Sign in to your existing Slides account, then connect your Oficina account from the account menu." |
| slides.home.local_action = "Sign in to your existing Slides account" |
| - |
| slides.home.failed_title = "Could not connect accounts" |
| - |
| slides.home.failed_body = "Return to Slides and start again." |
| - |
| slides.home.success_title = "Accounts connected" |
| - |
| -slides.home.success_body = "You can now use Home to sign in to this Slides account." |
| - |
| +slides.home.success_body = "You can now use your Oficina account to sign in to this Slides account." |
| slides.home.documents_action = "Back to Slides" |
| - |
| -slides.home.menu_action = "Connect Home account" |
| +slides.home.menu_action = "Connect your Oficina account" |
| slides.credits.title = "Open source" |
| slides.credits.help = "Help" |
diff --git a/internal/slides/presentation.go b/internal/slides/presentation.go| index 4e99a54..04761ab 100644 |
| --- a/internal/slides/presentation.go |
| +++ b/internal/slides/presentation.go |
| @@ -42,6 +42,7 @@ |
| package slides |
| import ( |
| + "amadan.net/oficina/estilo.git/chrome" |
| "crypto/rand" |
| "crypto/sha256" |
| "crypto/subtle" |
| @@ -211,12 +212,7 @@ func (a *app) presentationFor(w http.ResponseWriter, r *http.Request) (Presentat |
| // own, because the framework catalog words five statuses and this is |
| // not one of them (ui/partials/error-page.html). |
| func (a *app) gone(w http.ResponseWriter, r *http.Request) { |
| - w.WriteHeader(http.StatusGone) |
| - render(w, "errors", map[string]any{ |
| - "Status": http.StatusGone, |
| - "Title": rastrillo.T(r, "slides.present.over.title"), |
| - "Body": rastrillo.T(r, "slides.present.over.body"), |
| - }) |
| + a.errorPage(w, r, http.StatusGone, kindStatus, "") |
| } |
| // deckOf loads the deck a session is presenting. No visibility check: |
| @@ -376,20 +372,19 @@ func (a *app) createPresentation(r *http.Request, d Deck, actor, token string) ( |
| // --- the pages --- |
| -// presentShell is the account slot for the four capability screens. |
| -// They are all reachable with no session at all — a projector, a |
| -// phone, a stranger holding a follow link — so they carry the |
| -// share-link shell (templates/anon.html) rather than the account menu. |
| -type presentShell struct { |
| - SignInHref string |
| - SignInLabel string |
| -} |
| - |
| -func presentShellFor(r *http.Request) presentShell { |
| - return presentShell{ |
| - SignInHref: signinPath, |
| - SignInLabel: rastrillo.T(r, "slides.signin.title"), |
| - } |
| +// presentShellFor is the chrome of the four capability screens: none. |
| +// They are reachable with no session at all — a projector, a phone, a |
| +// stranger holding a follow link — and shown full screen, on a lectern |
| +// or in somebody's hand, so they take no bar (shared chrome spec §12.1, |
| +// Slides). They used to carry the share-link shell, which showed a |
| +// signed-in presenter "Account → Sign in". |
| +func presentShellFor(r *http.Request, parts ...string) shell { |
| + return shell{Frame: Frame{ |
| + Chrome: chrome.Bar{App: chrome.App{Name: appName}}, |
| + TitleParts: parts, |
| + Theme: chrome.ThemeAttr(chrome.Appearance(r)), |
| + Bare: true, |
| + }} |
| } |
| // commandButton is one control: an op, and what it says. Each renders |
| @@ -413,7 +408,7 @@ type controls struct { |
| // surfacePage is the fullscreen surface: GET /p/{session}, and the |
| // audience's follow view, which is the same page read-only. |
| type surfacePage struct { |
| - presentShell |
| + shell |
| Title string |
| // Session is the presentation's sqid, and is EMPTY on the |
| // audience's follow view (codex 5): a share-link holder must not |
| @@ -466,7 +461,7 @@ type surfacePage struct { |
| // presenterPage is GET /p/{session}/notes: the second screen. |
| type presenterPage struct { |
| - presentShell |
| + shell |
| Title string |
| DeckTitle string |
| Session string |
| @@ -517,7 +512,7 @@ type presenterPage struct { |
| // remotePage is GET /p/{session}/remote: the phone. |
| type remotePage struct { |
| - presentShell |
| + shell |
| Title string |
| DeckTitle string |
| Session string |
| @@ -545,7 +540,7 @@ type remotePage struct { |
| // pairPage is GET /r/{code}: the way in for a code somebody typed. |
| type pairPage struct { |
| - presentShell |
| + shell |
| Title string |
| Body string |
| Action string |
| @@ -626,7 +621,7 @@ func (a *app) renderSurface(w http.ResponseWriter, r *http.Request, d Deck, p Pr |
| q := r.URL.Query() |
| page := surfacePage{ |
| - presentShell: presentShellFor(r), |
| + shell: presentShellFor(r, view.Meta.Title), |
| Title: view.Meta.Title, |
| Session: opt.session, |
| StreamHref: opt.stream, |
| @@ -768,10 +763,10 @@ func (a *app) presenterNotes(w http.ResponseWriter, r *http.Request) { |
| now := time.Now().UTC() |
| page := presenterPage{ |
| - presentShell: presentShellFor(r), |
| - Title: rastrillo.T(r, "slides.present.notes.title"), |
| - DeckTitle: view.Meta.Title, |
| - Session: p.Sqid, |
| + shell: presentShellFor(r, rastrillo.T(r, "slides.present.notes.title"), view.Meta.Title), |
| + Title: rastrillo.T(r, "slides.present.notes.title"), |
| + DeckTitle: view.Meta.Title, |
| + Session: p.Sqid, |
| CurrentLabel: rastrillo.T(r, "slides.present.current"), |
| NextLabel: rastrillo.T(r, "slides.present.next.label"), |
| @@ -959,7 +954,7 @@ func (a *app) remote(w http.ResponseWriter, r *http.Request) { |
| defer h.Close(r.Context()) |
| page := remotePage{ |
| - presentShell: presentShellFor(r), |
| + shell: presentShellFor(r, rastrillo.T(r, "slides.remote.title"), view.Meta.Title), |
| Title: rastrillo.T(r, "slides.remote.title"), |
| DeckTitle: view.Meta.Title, |
| Session: p.Sqid, |
| @@ -1030,16 +1025,16 @@ func (a *app) remotePair(w http.ResponseWriter, r *http.Request) { |
| } |
| page := pairPage{ |
| - presentShell: presentShellFor(r), |
| - Title: rastrillo.T(r, "slides.remote.pair.title"), |
| - Body: rastrillo.T(r, "slides.remote.pair.body"), |
| - Action: "/r/" + code, |
| - Code: code, |
| - CodeLabel: rastrillo.T(r, "slides.remote.pair.code"), |
| - CodeHint: rastrillo.T(r, "slides.remote.pair.code.hint"), |
| - TokenLabel: rastrillo.T(r, "slides.remote.pair.token"), |
| - TokenHint: rastrillo.T(r, "slides.remote.pair.token.hint"), |
| - Submit: rastrillo.T(r, "slides.remote.pair.submit"), |
| + shell: presentShellFor(r, rastrillo.T(r, "slides.remote.pair.title")), |
| + Title: rastrillo.T(r, "slides.remote.pair.title"), |
| + Body: rastrillo.T(r, "slides.remote.pair.body"), |
| + Action: "/r/" + code, |
| + Code: code, |
| + CodeLabel: rastrillo.T(r, "slides.remote.pair.code"), |
| + CodeHint: rastrillo.T(r, "slides.remote.pair.code.hint"), |
| + TokenLabel: rastrillo.T(r, "slides.remote.pair.token"), |
| + TokenHint: rastrillo.T(r, "slides.remote.pair.token.hint"), |
| + Submit: rastrillo.T(r, "slides.remote.pair.submit"), |
| } |
| if token != "" { |
| // The code is live and the key is not its own: say so on the |
| @@ -1246,7 +1241,7 @@ func (a *app) presentCommand(w http.ResponseWriter, r *http.Request) { |
| count = len(view.Slides) |
| h.Close(r.Context()) |
| if count == 0 { |
| - ErrorPage(w, r, http.StatusUnprocessableEntity, "") |
| + a.errorPage(w, r, http.StatusUnprocessableEntity, kindStatus, "") |
| return |
| } |
| } |
| @@ -1283,7 +1278,7 @@ func (a *app) presentCommand(w http.ResponseWriter, r *http.Request) { |
| a.gone(w, r) |
| return |
| case errors.Is(err, errBadCommand): |
| - ErrorPage(w, r, http.StatusUnprocessableEntity, "") |
| + a.errorPage(w, r, http.StatusUnprocessableEntity, kindStatus, "") |
| return |
| default: |
| a.fail(w, r, "apply a presentation command", err) |
diff --git a/internal/slides/render.go b/internal/slides/render.go| index 5c2f61c..a484b69 100644 |
| --- a/internal/slides/render.go |
| +++ b/internal/slides/render.go |
| @@ -4,9 +4,14 @@ import ( |
| "bytes" |
| "embed" |
| "html/template" |
| + "io/fs" |
| "net/http" |
| + "testing/fstest" |
| + "amadan.net/oficina/estilo.git/base" |
| + "amadan.net/oficina/estilo.git/chrome" |
| "amadan.net/oficina/estilo.git/credits" |
| + estilomenu "amadan.net/oficina/estilo.git/menu" |
| "amadan.net/oficina/home/connect" |
| "amadan.net/oficina/slides/internal/slides/icons" |
| "amadan.net/rastrillo/rastrillo" |
| @@ -41,22 +46,81 @@ const DefaultLocale = "en" |
| // assets fingerprints static/: {{asset "static/tokens.css"}} in a |
| // template returns a content-hashed URL, served immutable-cacheable by |
| // the handler New mounts (app.go). |
| -var assets = rastrillo.NewAssets(appFS) |
| +// |
| +// The tree it serves is the app's own static/ plus estilo's shared |
| +// chrome, under static/estilo/: base.css, menu.css, chrome.css and the |
| +// optional chrome.js. Served from here rather than linked from estilo, |
| +// so they are fingerprinted like every other asset and a new estilo pin |
| +// is a new URL. |
| +var assets = rastrillo.NewAssets(staticFiles()) |
| + |
| +func staticFiles() fs.FS { |
| + files := fstest.MapFS{} |
| + err := fs.WalkDir(appFS, "static", func(name string, entry fs.DirEntry, err error) error { |
| + if err != nil || entry.IsDir() { |
| + return err |
| + } |
| + data, err := appFS.ReadFile(name) |
| + if err != nil { |
| + return err |
| + } |
| + files[name] = &fstest.MapFile{Data: data} |
| + return nil |
| + }) |
| + if err != nil { |
| + panic(err) |
| + } |
| + files["static/estilo/base.css"] = &fstest.MapFile{Data: base.CSS()} |
| + files["static/estilo/menu.css"] = &fstest.MapFile{Data: estilomenu.CSS()} |
| + files["static/estilo/chrome.css"] = &fstest.MapFile{Data: chrome.CSS()} |
| + files["static/estilo/chrome.js"] = &fstest.MapFile{Data: chrome.JS()} |
| + return files |
| +} |
| // pages is one *template.Template per page, each combining layout.html |
| // with that page's own file — kept separate so every page can define a |
| // template named "content" without colliding with the others. |
| var pages = map[string]*template.Template{} |
| -// publicPages are the screens a signed-out request can reach. They get |
| -// no account menu, because there is no account to show. |
| -var publicPages = []string{"signin", "errors", "home_link", "credits"} |
| +// pageFiles is every page and the app partials it is built with. Every |
| +// page gets the same layout and the same estilo bar; what differs |
| +// between a signed-in screen, the sign-in page, a share link and a |
| +// projector is the Frame the handler builds (chrome.go), not which |
| +// templates were parsed. Before round 2 that choice was made here, in |
| +// four groups (public, signed in, share link, capability), and the |
| +// signed-in presenter was shown the anonymous shell's "Account → Sign |
| +// in" because the group, not the request, decided. |
| +var pageFiles = map[string][]string{ |
| + "signin": nil, |
| + "signed_out": nil, |
| + "errors": nil, |
| + "home_link": nil, |
| + "credits": nil, |
| + "decks": nil, |
| + "viewer": nil, |
| + "editor": nil, |
| + "deck_notes": nil, |
| + "deck_new": nil, |
| + "deck_settings": nil, |
| + "deck_delete": nil, |
| + "deck_export": nil, |
| + "export_status": nil, |
| + "share": nil, |
| + "agents": nil, |
| + // The presenting screens: three share one partial, so the remote |
| + // and the presenter view cannot drift on what a button is called or |
| + // where it posts. |
| + "present": {"templates/present_controls.html"}, |
| + "present_notes": {"templates/present_controls.html"}, |
| + "remote": {"templates/present_controls.html"}, |
| + "remote_pair": nil, |
| +} |
| -// signedInPages are the screens behind the session gate. They are built |
| -// with templates/account.html parsed after the layout, so the shell's |
| -// account menu is defined once rather than in each page (see that |
| -// file's own comment). |
| -var signedInPages = []string{"decks", "viewer", "editor", "deck_notes", "deck_new", "deck_settings", "deck_delete", "deck_export", "export_status", "share", "agents"} |
| +// linkPages are names a handler uses for a page built from another |
| +// page's file. The viewer is ONE file serving the owner, a team member |
| +// and a stranger holding a share link (viewer.go says why); the Frame is |
| +// what tells them apart. |
| +var linkPages = map[string]string{"viewer_link": "viewer"} |
| // fragmentPages are the templates that are ALSO rendered without the |
| // layout: rastrillo's polling shim replaces one element with what it |
| @@ -69,54 +133,19 @@ var signedInPages = []string{"decks", "viewer", "editor", "deck_notes", "deck_ne |
| // about the same job if they are one template. |
| var fragmentPages = []string{"export_status"} |
| -// linkPages are screens rendered from a signed-in page's own template |
| -// for somebody who is not signed in — today, exactly one: the viewer, |
| -// served over a share link (GET /s/{token}). |
| -// |
| -// The key is what a handler names; the value is the template file. That |
| -// is the whole point of the map: the viewer is ONE file serving both |
| -// audiences (viewer.go says why), built twice so the shell around it |
| -// can differ. templates/anon.html replaces account.html in the second |
| -// build, because a stranger holding a link has no account to show and |
| -// an empty account menu is worse than none. |
| -var linkPages = map[string]string{"viewer_link": "viewer"} |
| - |
| -// capabilityPages are the presenting screens (presentation.go): the |
| -// fullscreen surface, the presenter view, the phone remote and the |
| -// pairing page. Every one of them is addressed by a capability rather |
| -// than by a session — a projector, a phone and an audience have no |
| -// account here — so they carry templates/anon.html's shell like the |
| -// share-link viewer does. |
| -// |
| -// A map of extra files rather than a list of names, because three of |
| -// the four share one partial: present_controls.html defines the |
| -// command buttons once, so the remote and the presenter view cannot |
| -// drift on what a button is called or where it posts. |
| -var capabilityPages = map[string][]string{ |
| - "present": {"templates/anon.html", "templates/present_controls.html"}, |
| - "present_notes": {"templates/anon.html", "templates/present_controls.html"}, |
| - "remote": {"templates/anon.html", "templates/present_controls.html"}, |
| - "remote_pair": {"templates/anon.html"}, |
| -} |
| - |
| func init() { |
| - for _, name := range publicPages { |
| - pages[name] = buildPage(name) |
| - if name == "credits" { |
| - pages[name] = template.Must(buildPage(name, "templates/help.html").ParseFS(credits.Templates(), "templates/*.html")) |
| - } |
| - if name == "home_link" { |
| - pages[name] = template.Must(pages[name].ParseFS(connect.Templates(), "templates/home_link.html")) |
| + for name, shared := range pageFiles { |
| + t := buildPage(name, shared...) |
| + switch name { |
| + case "credits": |
| + t = template.Must(t.ParseFS(credits.Templates(), "templates/*.html")) |
| + case "home_link": |
| + t = template.Must(t.ParseFS(connect.Templates(), "templates/home_link.html")) |
| } |
| - } |
| - for _, name := range signedInPages { |
| - pages[name] = buildPage(name, "templates/account.html", "templates/help.html") |
| + pages[name] = t |
| } |
| for name, file := range linkPages { |
| - pages[name] = buildPage(file, "templates/anon.html") |
| - } |
| - for name, shared := range capabilityPages { |
| - pages[name] = buildPage(name, shared...) |
| + pages[name] = pages[file] |
| } |
| for _, name := range fragmentPages { |
| fragments[name] = buildFragment(name) |
| @@ -139,14 +168,15 @@ func buildFragment(name string) *template.Template { |
| ParseFS(appFS, "templates/"+name+".html")) |
| } |
| -// buildPage parses one page: ui's partials, then the layout, then any |
| -// shared app partials, then the page's own file. Order is the whole |
| -// mechanism — a later definition of a name replaces an earlier one, so |
| -// account.html's "account" wins over the layout's empty default. |
| +// buildPage parses one page: ui's partials, estilo's menu and chrome |
| +// templates, then the layout, then any shared app partials, then the |
| +// page's own file. Order is the whole mechanism — a later definition of |
| +// a name replaces an earlier one, so a page's "content" wins over the |
| +// layout's empty default. |
| func buildPage(name string, shared ...string) *template.Template { |
| files := append([]string{"templates/layout.html"}, shared...) |
| files = append(files, "templates/"+name+".html") |
| - return template.Must( |
| + t := template.Must(template.New("layout"). |
| // ui's partials and their helpers first, so every page can |
| // call one without registering anything. WithIcons points |
| // ui's two icon seams at the app's own set |
| @@ -157,13 +187,14 @@ func buildPage(name string, shared ...string) *template.Template { |
| // the chosen delivery needs, empty for the vendored-inline |
| // default, so the layout calls it unconditionally and |
| // switching delivery later needs no template edit. |
| - template.Must(template.New("layout"). |
| - Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))). |
| - Funcs(template.FuncMap{"asset": assets.Path}). |
| - ParseFS(ui.Templates(), "*.html")). |
| - // Then the app's own: the shell, any shared partial, and |
| - // this page's body. |
| - ParseFS(appFS, files...)) |
| + Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))). |
| + Funcs(template.FuncMap{"asset": assets.Path}). |
| + ParseFS(ui.Templates(), "*.html")) |
| + t = template.Must(t.ParseFS(estilomenu.Templates(), "templates/*.html")) |
| + t = template.Must(t.ParseFS(chrome.Templates(), "templates/*.html")) |
| + // Then the app's own: the shell, any shared partial, and this |
| + // page's body. |
| + return template.Must(t.ParseFS(appFS, files...)) |
| } |
| // render executes name's layout into a buffer before anything touches |
| @@ -192,14 +223,3 @@ func renderFragment(w http.ResponseWriter, name, define string, data any) { |
| w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| buf.WriteTo(w) |
| } |
| - |
| -// ErrorPage renders the "errors" page — templates/errors.html, which |
| -// wraps ui's error-page partial in the layout — for status inside ref |
| -// (rastrillo.NewRef, or empty when there is none to show). It is the |
| -// rastrillo.ErrorPageFunc cmd/slides/main.go points Options.ErrorPage at, |
| -// so a panic gets the same page a handled failure would; wire it to |
| -// Ctx.ErrorPage too as the app grows handlers of its own. |
| -func ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) { |
| - w.WriteHeader(status) |
| - render(w, "errors", map[string]any{"Status": status, "Ref": ref}) |
| -} |
diff --git a/internal/slides/share.go b/internal/slides/share.go| index 34bb8dc..3e921d0 100644 |
| --- a/internal/slides/share.go |
| +++ b/internal/slides/share.go |
| @@ -139,7 +139,7 @@ func (a *app) renderSharePage(w http.ResponseWriter, r *http.Request, u User, d |
| base := "/d/" + d.Sqid |
| page := sharePage{ |
| - shell: shellFor(r, u), |
| + shell: a.shellFor(r, u, sharePageFrame(r, d)), |
| Title: rastrillo.T(r, "slides.share.title"), |
| DeckTitle: d.Title, |
| Lead: rastrillo.T(r, "slides.share.lead"), |
| @@ -355,7 +355,9 @@ func (a *app) sharedDeck(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| - a.renderViewer(w, r, d, modeLink, "/s/"+token, shell{}) |
| + // A stranger's page: the brand is plain text and "Sign in" carries |
| + // no return path, because this path is the capability. |
| + a.renderViewer(w, r, d, modeLink, "/s/"+token, a.publicShell(r, frame{parts: []string{d.Title}, public: true})) |
| } |
| // --- GET /s/{token}/stream --- |
| @@ -474,3 +476,12 @@ func (a *app) deckForShareLink(w http.ResponseWriter, r *http.Request, token str |
| } |
| return d, link, true |
| } |
| + |
| +// sharePageFrame is the share page's chrome. Appearance returns to the |
| +// bare path: this page's query can carry the one-time link (?new=), and |
| +// reflecting a query into the page would print it again. |
| +func sharePageFrame(r *http.Request, d Deck) frame { |
| + f := onDeck(rastrillo.T(r, "slides.share.title"), d.Title) |
| + f.ret = "/d/" + d.Sqid + "/share" |
| + return f |
| +} |
diff --git a/internal/slides/templates/account.html b/internal/slides/templates/account.html| deleted file mode 100644 |
| index a357b68..0000000 |
| --- a/internal/slides/templates/account.html |
| +++ /dev/null |
| @@ -1,50 +0,0 @@ |
| -{{/* account — the shell's account menu body, for every signed-in |
| - screen. |
| - |
| - A file of its own rather than a block each page redefines: the |
| - layout owns the details/summary, an override supplies only what |
| - sits inside [rst-dropdown-menu], and five copies of these eight |
| - lines would drift within a week. render.go parses this after |
| - layout.html for every signed-in page, so this definition replaces |
| - the layout's empty default; the sign-in page does not get it, |
| - because nobody is signed in there to have an account. |
| - |
| - Every page's view data embeds `shell` (decks.go), which is where |
| - .Person and .SignoutLabel come from. */}} |
| -{{/* This file owns "head" for every signed-in page, because that is |
| - where the one rule below has to live. A page that defines its own |
| - "head" replaces this one and loses the rule — so no signed-in page |
| - does. A page with something of its own to put in <head> defines |
| - "page-head" instead (layout.html says why), and this definition |
| - calls it, so the rule below and the page's own markup both land. */}} |
| -{{define "head"}}<style> |
| - /* The address the account menu is signed in as: a label above the |
| - one control, not a control itself. Muted and small so it reads as |
| - context rather than as a second thing to click, and padded to the |
| - same inset as the button below it so the two line up. */ |
| - [slides-account-who] { |
| - color: var(--rst-text-muted); |
| - font-size: var(--rst-fs-sm); |
| - margin: 0; |
| - overflow-wrap: anywhere; |
| - padding: 0.4rem 0.65rem; |
| - } |
| -</style>{{template "page-head" .}}{{end}} |
| -{{define "account"}} |
| -<p slides-account-who>{{.Person}}</p> |
| -<hr> |
| -{{/* Agents (agents.go): the tokens that let a program drive this |
| - person's decks. A link and not a form — it is a screen, and |
| - minting is a decision made on that screen rather than from a |
| - menu. */}} |
| -{{if .HomeLabel}}<a href="/account/home">{{.HomeLabel}}</a>{{end}} |
| -<a href="/agents">{{.AgentsLabel}}</a> |
| -{{/* Sign-out is a POST, so it is a form and not a link: csrf.Protect |
| - guards state-changing methods, and a GET that ends a session is one |
| - a link prefetch can fire. A form also works with scripts off, which |
| - a fetch() would not — tokens.css already styles a bare button |
| - inside [rst-dropdown-menu] as a menu item. */}} |
| -<form method="post" action="/signout"> |
| - <button type="submit">{{.SignoutLabel}}</button> |
| -</form> |
| -{{end}} |
diff --git a/internal/slides/templates/agents.html b/internal/slides/templates/agents.html| index 4c4804c..047b87c 100644 |
| --- a/internal/slides/templates/agents.html |
| +++ b/internal/slides/templates/agents.html |
| @@ -11,13 +11,12 @@ |
| line of prose, because the whole job of this screen at that moment |
| is "select this and copy it", and a field is what a browser lets |
| you select cleanly with no script at all. */}} |
| -{{define "title"}}{{.Title}}{{end}} |
| {{define "content"}} |
| {{template "page-header" dict "Title" .Title}} |
| {{if .NewToken}} |
| <div rst-box> |
| - {{template "callout" dict "Tone" "positive" "Title" .NewTitle "Body" .NewHint}} |
| + {{template "callout" dict "Tone" "warning" "Title" .NewTitle "Body" .NewHint}} |
| {{/* Hand-written rather than {{template "field-text"}} for one |
| attribute: readonly, which that partial's contract does not |
| carry. The markup is field-text's own, attribute for attribute, |
diff --git a/internal/slides/templates/anon.html b/internal/slides/templates/anon.html| deleted file mode 100644 |
| index b5ba228..0000000 |
| --- a/internal/slides/templates/anon.html |
| +++ /dev/null |
| @@ -1,16 +0,0 @@ |
| -{{/* anon — the shell's account menu for a screen nobody is signed in on. |
| - |
| - There is exactly one such screen: the share-link viewer, |
| - GET /s/{token}. render.go builds viewer.html twice, once with |
| - account.html and once with this file, so the same viewer can be |
| - served to a signed-in person and to a stranger holding a link |
| - without either shell pretending to be the other. |
| - |
| - The layout renders the account disclosure unconditionally, so the |
| - choice is not whether to have one — it is what to put in it. A |
| - sign-in link is the honest answer: somebody reading a deck over a |
| - link may well have an account here, and the door is the only thing |
| - this app can offer them. */}} |
| -{{define "account"}} |
| -<a href="{{.SignInHref}}">{{.SignInLabel}}</a> |
| -{{end}} |
diff --git a/internal/slides/templates/credits.html b/internal/slides/templates/credits.html| index 806daa5..a424e6d 100644 |
| --- a/internal/slides/templates/credits.html |
| +++ b/internal/slides/templates/credits.html |
| @@ -1,4 +1,2 @@ |
| -{{define "title"}}{{.Title}}{{end}} |
| -{{define "page-head"}}<link rel="stylesheet" href="/static/credits.css"><style>[rst-shell-brand] { min-block-size: 24px; display: inline-flex; align-items: center; }</style>{{end}} |
| -{{define "account"}}<a href="{{.SignInHref}}">{{.SignInLabel}}</a>{{end}} |
| +{{define "page-head"}}<link rel="stylesheet" href="/static/credits.css">{{end}} |
| {{define "content"}}{{template "estilo/credits" .Credits}}{{end}} |
diff --git a/internal/slides/templates/deck_delete.html b/internal/slides/templates/deck_delete.html| index cdf1e98..3e77ed8 100644 |
| --- a/internal/slides/templates/deck_delete.html |
| +++ b/internal/slides/templates/deck_delete.html |
| @@ -6,7 +6,6 @@ |
| nothing, Cancel is a real link and comes first in the DOM, and the |
| whole thing works with scripts off — which is the point of a page |
| rather than a modal. */}} |
| -{{define "title"}}{{.Title}}{{end}} |
| {{define "content"}} |
| {{template "back-nav" dict "Href" .BackHref "Label" .BackLabel}} |
| {{template "page-header" dict "Title" .Title}} |
diff --git a/internal/slides/templates/deck_export.html b/internal/slides/templates/deck_export.html| index 8f69163..f99cdbf 100644 |
| --- a/internal/slides/templates/deck_export.html |
| +++ b/internal/slides/templates/deck_export.html |
| @@ -8,10 +8,9 @@ |
| The notice is where a refused start lands: a deck over the slide |
| cap, or a person who already has four exports running. Neither is |
| a fault, so neither is an error page. */}} |
| -{{define "title"}}{{.Title}}{{end}} |
| {{define "content"}} |
| {{template "back-nav" dict "Href" .BackHref "Label" .BackLabel}} |
| -{{template "page-header" dict "Title" .Title "Sub" .DeckTitle}} |
| +{{template "page-header" dict "Title" .Title}} |
| {{template "form-error" .Notice}} |
| <form method="post" action="{{.Action}}"> |
| <div rst-box> |
diff --git a/internal/slides/templates/deck_new.html b/internal/slides/templates/deck_new.html| index 3cbd34d..4bc3109 100644 |
| --- a/internal/slides/templates/deck_new.html |
| +++ b/internal/slides/templates/deck_new.html |
| @@ -4,7 +4,6 @@ |
| controls come from ui: a hand-rolled <label><input> validates fine |
| and renders as ragged inline labels over an unstyled button, which |
| is exactly the failure nothing tells you about. */}} |
| -{{define "title"}}{{.Title}}{{end}} |
| {{define "content"}} |
| {{template "page-header" dict "Title" .Title "Sub" .Body}} |
| <div rst-box> |
diff --git a/internal/slides/templates/deck_notes.html b/internal/slides/templates/deck_notes.html| index 6979be3..5e5e3fb 100644 |
| --- a/internal/slides/templates/deck_notes.html |
| +++ b/internal/slides/templates/deck_notes.html |
| @@ -9,10 +9,9 @@ |
| A slide with no notes says so. On a page being read while |
| presenting, a heading over nothing is indistinguishable from a |
| page that failed to load. */}} |
| -{{define "title"}}{{.Title}}{{end}} |
| {{define "content"}} |
| {{template "back-nav" dict "Href" .BackHref "Label" .BackLabel}} |
| -{{template "page-header" dict "Title" .Title "Sub" .DeckTitle}} |
| +{{template "page-header" dict "Title" .Title}} |
| <div rst-box> |
| <p>{{.Body}}</p> |
| </div> |
diff --git a/internal/slides/templates/deck_settings.html b/internal/slides/templates/deck_settings.html| index 6262db1..71225c8 100644 |
| --- a/internal/slides/templates/deck_settings.html |
| +++ b/internal/slides/templates/deck_settings.html |
| @@ -9,7 +9,6 @@ |
| Deleting is not a control on this form. It is a link to its own |
| page, which asks the question and carries the only button that |
| answers it. */}} |
| -{{define "title"}}{{.Title}}{{end}} |
| {{define "content"}} |
| {{template "back-nav" dict "Href" .BackHref "Label" .BackLabel}} |
| {{template "page-header" dict "Title" .Title}} |
diff --git a/internal/slides/templates/decks.html b/internal/slides/templates/decks.html| index 8a61f66..0f5e27f 100644 |
| --- a/internal/slides/templates/decks.html |
| +++ b/internal/slides/templates/decks.html |
| @@ -9,7 +9,6 @@ |
| The rows live in [rst-list], which holds rows and nothing else. The |
| rung rides as a status-pill inside each row: always visible text, |
| never colour alone. */}} |
| -{{define "title"}}{{.Title}}{{end}} |
| {{define "content"}} |
| {{template "page-header" dict |
| "Title" .Title |
diff --git a/internal/slides/templates/editor.html b/internal/slides/templates/editor.html| index 3aada8a..f04e691 100644 |
| --- a/internal/slides/templates/editor.html |
| +++ b/internal/slides/templates/editor.html |
| @@ -28,7 +28,6 @@ |
| The <noscript> is spec §8's floor said out loud — the editor is |
| the one surface that needs scripts — and it points at the viewer, |
| which does not. */}} |
| -{{define "title"}}{{.Title}}{{end}} |
| {{define "page-head"}}<link rel="stylesheet" href="{{asset "static/slides.css"}}"> |
| <link rel="stylesheet" href="{{asset "static/editor.css"}}"> |
| <script type="module" src="{{.BundleSrc}}"></script>{{end}} |
diff --git a/internal/slides/templates/errors.html b/internal/slides/templates/errors.html| index 591c9e6..da57366 100644 |
| --- a/internal/slides/templates/errors.html |
| +++ b/internal/slides/templates/errors.html |
| @@ -1,16 +1,8 @@ |
| -{{/* errors.html — the app's error page: layout.html's content block |
| - rendered by render.go's ErrorPage, which cmd/<app>/main.go points |
| - Options.ErrorPage at (so a panic gets this page) and which the app |
| - can wire to Ctx.ErrorPage too as handlers grow. All the wording |
| - and layout live in the partial below — restyle the error page by |
| - editing this file, or hand it Title/Body/HomeHref/BackHref to |
| - override the partial's own catalog defaults; see |
| - ui/partials/error-page.html for what it accepts. */}} |
| -{{/* Title and Body are optional overrides, and they are what lets a |
| - status the framework catalog does not word carry words of its own: |
| - the 410 for a presentation that is over (presentation.go's gone) |
| - is the first, and it would otherwise render the generic pair. A |
| - caller that passes neither is unchanged — the partial falls back |
| - to the catalog exactly as it did. */}} |
| -{{define "content"}}{{template "error-page" dict |
| - "Status" .Status "Ref" .Ref "Title" .Title "Body" .Body}}{{end}} |
| +{{/* errors.html — the app's error page, inside the normal layout and |
| + bar (errors.go). The body is estilo's estilo-error, which emits |
| + rastrillo's rst-error markup with a way back the caller names ("Go |
| + to Slides", "Try again"); rastrillo's own error-page partial |
| + hard-codes "Start page". The bar shows whoever is signed in: the |
| + page used to be in the public template group and always looked |
| + signed out. Data: errorView. */}} |
| +{{define "content"}}{{template "estilo-error" .Error}}{{end}} |
diff --git a/internal/slides/templates/export_status.html b/internal/slides/templates/export_status.html| index 9c8ee5f..1cf0f5c 100644 |
| --- a/internal/slides/templates/export_status.html |
| +++ b/internal/slides/templates/export_status.html |
| @@ -13,7 +13,6 @@ |
| is what stops the loop. With scripts off the meta refresh below |
| does the same job more slowly — and only while the job is running, |
| or a finished page would refresh for ever. */}} |
| -{{define "title"}}{{.Title}}{{end}} |
| {{define "page-head"}}{{if .Running}}<noscript><meta http-equiv="refresh" content="{{.PollSeconds}}"></noscript>{{end}}{{end}} |
| {{define "export-body"}} |
diff --git a/internal/slides/templates/help.html b/internal/slides/templates/help.html| deleted file mode 100644 |
| index 0a220b0..0000000 |
| --- a/internal/slides/templates/help.html |
| +++ /dev/null |
| @@ -1,4 +0,0 @@ |
| -{{define "help"}}<details rst-dropdown name="rst-menus"> |
| -<summary>{{.HelpLabel}}</summary> |
| -<div rst-dropdown-menu><a href="/help/open-source">{{.CreditsLabel}}</a></div> |
| -</details>{{end}} |
diff --git a/internal/slides/templates/home_link.html b/internal/slides/templates/home_link.html| index 9000fcd..a665149 100644 |
| --- a/internal/slides/templates/home_link.html |
| +++ b/internal/slides/templates/home_link.html |
| @@ -1,2 +1 @@ |
| -{{define "title"}}{{.Title}}{{end}} |
| {{define "content"}}{{template "home-connect" .}}{{end}} |
diff --git a/internal/slides/templates/layout.html b/internal/slides/templates/layout.html| index 5da7e09..2b04a4f 100644 |
| --- a/internal/slides/templates/layout.html |
| +++ b/internal/slides/templates/layout.html |
| @@ -1,101 +1,69 @@ |
| {{define "layout"}}<!doctype html> |
| -<html lang="{{block "lang" .}}en{{end}}" dir="{{block "dir" .}}ltr{{end}}"> |
| +{{/* layout — every page's document: the head, the estilo bar and <main>. |
| + |
| + The bar is estilo's (chrome.Bar, built once per request in |
| + chrome.go), in place of rastrillo's topbar shell: that bar was 45px |
| + signed out and 51px signed in, because the Account summary was |
| + taller than the bar and only existed once signed in, so signing in, |
| + and every hop between an error page and any other page, moved the |
| + whole page down 6px. estilo's is 52px at every width, signed in or |
| + out, with the account always last in the same corner. |
| + |
| + Bare pages (the presenting screens: the projector surface, the |
| + presenter view, the remote and the pairing page) have no bar at all. |
| + They are addressed by a capability and shown on a projector, a |
| + lectern or a phone; a bar there was chrome nobody could use, and a |
| + signed-in presenter was shown "Account → Sign in" in it. |
| + |
| + data-theme is written into the first bytes of the response, from |
| + the Appearance cookie read server-side (chrome.Appearance), so there |
| + is no flash. It is ABSENT for "system", and that is the mechanism: |
| + theme.css's toggle is :root[data-theme="light"|"dark"], and with no |
| + attribute the browser follows the operating system. |
| + |
| + The <title> is built here and only here, "{part} · {part} · Slides", |
| + so no handler can drift from the suite's separator (spec §10). |
| + |
| + <meta charset> comes first, before anything that could be read as |
| + text. The stylesheets are in the suite's fixed order: tokens, theme, |
| + brand, then estilo's base, menu and chrome, then the page's own |
| + (page-head). */}} |
| +<html lang="{{block "lang" .}}en{{end}}" dir="{{block "dir" .}}ltr{{end}}"{{with .Frame.Theme}} data-theme="{{.}}"{{end}}> |
| <head> |
| +<meta charset="utf-8"> |
| +<meta name="viewport" content="width=device-width, initial-scale=1"> |
| +<title>{{range .Frame.TitleParts}}{{.}} · {{end}}{{.Frame.Chrome.App.Name}}</title> |
| <link rel="icon" type="image/png" sizes="32x32" href="{{asset "static/brand/icon-32.png"}}"> |
| <link rel="icon" type="image/svg+xml" href="{{asset "static/brand/icon.svg"}}"> |
| <link rel="apple-touch-icon" sizes="180x180" href="{{asset "static/brand/apple-touch-icon.png"}}"> |
| -<link rel="stylesheet" href="{{asset "static/brand/brand.css"}}"> |
| -<meta charset="utf-8"> |
| -<meta name="viewport" content="width=device-width"> |
| -{{/* Slides, not a catalog key: the app's name is a proper noun and |
| - reads the same in every locale. Every real page overrides this |
| - block with its own screen name. */}} |
| -<title>{{block "title" .}}Slides{{end}}</title> |
| <link rel="stylesheet" href="{{asset "static/tokens.css"}}"> |
| <link rel="stylesheet" href="{{asset "static/theme.css"}}"> |
| +<link rel="stylesheet" href="{{asset "static/brand/brand.css"}}"> |
| +<link rel="stylesheet" href="{{asset "static/estilo/base.css"}}"> |
| +<link rel="stylesheet" href="{{asset "static/estilo/menu.css"}}"> |
| +<link rel="stylesheet" href="{{asset "static/estilo/chrome.css"}}"> |
| <script defer src="{{asset "static/rastrillo.js"}}"></script> |
| +<script defer src="{{asset "static/estilo/chrome.js"}}"></script> |
| {{iconAssets}} |
| <script defer src="{{asset "static/select.js"}}"></script> |
| <script defer src="{{asset "static/calendar.js"}}"></script> |
| <script defer src="{{asset "static/datetime.js"}}"></script> |
| -{{/* head is the app's own slot in <head>: a favicon, a meta tag, one |
| - more stylesheet. Empty by default, last in the head so an app's |
| - own CSS wins over the framework's, and the only block in the |
| - three shells that is not chrome. |
| - |
| - page-head nested inside it is this app's own addition, and it |
| - exists because head has an owner already: account.html defines it |
| - for every signed-in screen, to carry the one rule the account menu |
| - needs. A page defining "head" of its own would replace that and |
| - silently lose the rule, so a page defines "page-head" instead and |
| - both land — whichever of the two shells is in play, since a screen |
| - built without account.html (templates/anon.html's share-link |
| - viewer) still resolves page-head through this default. */}} |
| -{{block "head" .}}{{block "page-head" .}}{{end}}{{end}} |
| +{{/* page-head is a page's own slot in <head>: one more stylesheet, a |
| + module, a meta tag. Last, so a page's own CSS wins. */}} |
| +{{block "page-head" .}}{{end}} |
| </head> |
| <body> |
| -<div rst-shell-topbar> |
| -<a rst-skip href="#main">{{T "rastrillo.ui.shell_skip"}}</a> |
| -<header rst-shell-bar> |
| -{{/* The app's own name rather than the framework default "Home": a |
| - bar that says "Home" tells a person nothing about which of their |
| - tabs they are looking at. A proper noun, so not a catalog key. */}} |
| -{{block "brand" .}}<a rst-shell-brand estilo-brand href="/"><img estilo-brand-icon src="{{asset "static/brand/icon.svg"}}" alt="" width="28" height="28">Slides</a>{{end}} |
| -{{/* The narrow layout. Below 800px the bar's tail — nav, account, |
| - locale — hides behind this disclosure; above it, tokens.css hides |
| - the summary and gives [rst-shell-tail] display: contents, so the |
| - three blocks generate no box of their own and lay out as flex |
| - items of the bar, exactly as they did before this element existed. |
| - Laid out as direct children, not made into them: see the depth |
| - note at the foot of this comment. The sidebar collapses at the |
| - same width behind the same icon: two shells, one idiom. |
| - |
| - name="rst-shell-menu" — a group of its own, and NOT rst-menus, |
| - which is exactly the group this must not join. <details name> |
| - exclusivity is document-wide, so a disclosure in the menus' group |
| - would be closed by the account menu it just revealed: on a phone, |
| - opening the account would take the navigation away with it. Two |
| - gates hold the name (TestEveryMenuDefaultsToTheSharedExclusivityGroup |
| - reads it off the layout, and the browser drive opens the account |
| - menu inside the collapsed tail and asserts this is still open), so |
| - read this paragraph as the reason for them rather than as advice |
| - to change the attribute below. |
| - |
| - The tail is also a SIBLING rather than this element's content, |
| - which is how [rst-shell-chrome] does it: a closed <details> hides |
| - its own content, and the wide layout cannot be recovered from |
| - inside one. |
| - |
| - The three block names below are unchanged — nav, account, locale. |
| - Apps override them, and that contract is gated. What DID change is |
| - depth: nav, account and locale are now grandchildren of |
| - [rst-shell-bar] through [rst-shell-tail]. display: contents gives |
| - their boxes back above 800px; it does not give the selector back, |
| - so an app stylesheet or script written as |
| - `[rst-shell-bar] > [rst-shell-nav]` stops matching at every width. |
| - Use a descendant selector, or target [rst-shell-nav] directly. |
| - docs/site/templates.md says the same thing where an upgrader will |
| - meet it. */}} |
| -<details rst-shell-menu name="rst-shell-menu"><summary>{{icon "menu"}}{{T "rastrillo.ui.shell_menu"}}</summary></details> |
| -<div rst-shell-tail> |
| -{{block "help" .}}{{end}} |
| -<nav rst-shell-nav>{{block "nav" .}}{{end}}</nav> |
| -<details rst-dropdown rst-shell-account name="rst-menus"> |
| -<summary>{{T "rastrillo.ui.shell_account"}}<span rst-caret aria-hidden="true">{{icon "chevron-down"}}</span></summary> |
| -{{/* account here is the dropdown's MENU BODY, not the whole menu: the |
| - layout owns the details/summary, so an override supplies only the |
| - links that sit inside [rst-dropdown-menu]. Sidebar's account block |
| - is the other shape — a bare slot in the rail — so moving a block |
| - between the two shells needs an edit. */}} |
| -<div rst-dropdown-menu>{{block "account" .}}{{end}}</div> |
| -</details> |
| -{{block "locale" .}}{{end}} |
| -</div> |
| -</header> |
| -<main rst-page id="main"> |
| +{{/* [estilo-shell] wraps every page and has no styling of its own. The |
| + "main" block lets a page supply its own <main> (the projector |
| + surface, which is the whole viewport and not a column). */}} |
| +<div estilo-shell> |
| +{{- if not .Frame.Bare}} |
| +<a rst-skip href="#main">{{.Frame.Chrome.Labels.Skip}}</a> |
| +{{template "estilo-bar" .Frame.Chrome}} |
| +{{- end}} |
| +{{block "main" .}}<main rst-page id="main"> |
| {{template "content" .}} |
| -</main> |
| -<footer rst-shell-foot>{{block "foot" .}}{{end}}</footer> |
| +</main>{{end}} |
| </div> |
| </body> |
| </html> |
diff --git a/internal/slides/templates/present.html b/internal/slides/templates/present.html| index b2c89fc..b6499fd 100644 |
| --- a/internal/slides/templates/present.html |
| +++ b/internal/slides/templates/present.html |
| @@ -10,7 +10,6 @@ |
| Nothing here needs the script. With it, static/present.js swaps |
| the slide when the session moves; without it, the follow view has |
| a meta refresh and the presenter has next and previous. */}} |
| -{{define "title"}}{{.Title}}{{end}} |
| {{define "page-head"}}<link rel="stylesheet" href="{{asset "static/slides.css"}}"> |
| {{if .Refresh}}{{/* The scriptless heartbeat: an audience with no |
| JavaScript still moves with the presenter, five seconds behind. |
diff --git a/internal/slides/templates/present_notes.html b/internal/slides/templates/present_notes.html| index d8d9469..b1517ce 100644 |
| --- a/internal/slides/templates/present_notes.html |
| +++ b/internal/slides/templates/present_notes.html |
| @@ -10,7 +10,6 @@ |
| (presentation.go says why). Coming back to this URL later shows |
| the talk and no secret, because there is no secret left to show: |
| only the hash was stored. */}} |
| -{{define "title"}}{{.Title}}{{end}} |
| {{define "page-head"}}<link rel="stylesheet" href="{{asset "static/slides.css"}}"> |
| <script defer src="{{asset "static/present.js"}}"></script> |
| <style> |
| @@ -130,7 +129,7 @@ |
| {{if .Pair}} |
| <div rst-box> |
| - {{template "callout" dict "Tone" "positive" "Title" .PairTitle "Body" .PairBody}} |
| + {{template "callout" dict "Tone" "info" "Title" .PairTitle "Body" .PairBody}} |
| <div slides-pair> |
| {{.QR}} |
| <div slides-pair-fields> |
diff --git a/internal/slides/templates/remote.html b/internal/slides/templates/remote.html| index 8e984aa..17e3769 100644 |
| --- a/internal/slides/templates/remote.html |
| +++ b/internal/slides/templates/remote.html |
| @@ -10,7 +10,6 @@ |
| The script makes it feel live (a swipe, and the slide updating |
| when somebody else moves it). Nothing here needs it: with scripts |
| off each button posts and comes straight back. */}} |
| -{{define "title"}}{{.Title}}{{end}} |
| {{define "page-head"}}<link rel="stylesheet" href="{{asset "static/slides.css"}}"> |
| <script defer src="{{asset "static/present.js"}}"></script> |
| <style> |
diff --git a/internal/slides/templates/remote_pair.html b/internal/slides/templates/remote_pair.html| index eb59e5a..e40c941 100644 |
| --- a/internal/slides/templates/remote_pair.html |
| +++ b/internal/slides/templates/remote_pair.html |
| @@ -9,7 +9,6 @@ |
| resolves a code and a key to a URL — so the answer is a redirect |
| to the remote, and a person who bookmarks that has bookmarked |
| their remote rather than a form submission they cannot repeat. */}} |
| -{{define "title"}}{{.Title}}{{end}} |
| {{define "content"}} |
| {{template "page-header" dict "Title" .Title "Sub" .Body}} |
| <div rst-box> |
diff --git a/internal/slides/templates/share.html b/internal/slides/templates/share.html| index f39e85a..9e7611c 100644 |
| --- a/internal/slides/templates/share.html |
| +++ b/internal/slides/templates/share.html |
| @@ -9,14 +9,13 @@ |
| of prose, because the whole job of this screen at that moment is |
| "select this and copy it", and a field is the thing a browser lets |
| you select cleanly with no script at all. */}} |
| -{{define "title"}}{{.Title}}{{end}} |
| {{define "content"}} |
| {{template "back-nav" dict "Href" .BackHref "Label" .BackLabel}} |
| -{{template "page-header" dict "Title" .Title "Sub" .DeckTitle}} |
| +{{template "page-header" dict "Title" .Title}} |
| {{if .NewURL}} |
| <div rst-box> |
| - {{template "callout" dict "Tone" "positive" "Title" .NewTitle "Body" .NewHint}} |
| + {{template "callout" dict "Tone" "warning" "Title" .NewTitle "Body" .NewHint}} |
| {{/* Hand-written rather than {{template "field-text"}} for one |
| attribute: readonly, which that partial's contract does not |
| carry. The markup is field-text's own, attribute for attribute, |
diff --git a/internal/slides/templates/signed_out.html b/internal/slides/templates/signed_out.html| new file mode 100644 |
| index 0000000..899947f |
| --- /dev/null |
| +++ b/internal/slides/templates/signed_out.html |
| @@ -0,0 +1,6 @@ |
| +{{/* signed_out — where Sign out lands (auth.go's renderSignedOut), |
| + standalone and with Home alike: it says what happened, and offers |
| + Sign in. Before this, standalone Sign out dropped a person on the |
| + sign-in form with no word about it, which read like an error. Data: |
| + signedOutView. */}} |
| +{{define "content"}}{{template "estilo-signed-out" .Notice}}{{end}} |
diff --git a/internal/slides/templates/signin.html b/internal/slides/templates/signin.html| index 4052f5e..0fb9491 100644 |
| --- a/internal/slides/templates/signin.html |
| +++ b/internal/slides/templates/signin.html |
| @@ -1,111 +1,15 @@ |
| -{{/* signin — GET /signin (auth.go's signin handler). This is the page |
| - rastrillo/auth's own handlers never render themselves |
| - (magic-links.md: "the sign-in page stays yours"): Begin, Callback |
| - and Verify report every outcome by redirecting back here with a |
| - query the handler reads. It resolves those into already-translated |
| - Sent/ErrorMessage strings; this template only decides whether to |
| - show them. |
| +{{/* signin — GET /signin (auth.go's signin handler): estilo's sign-in |
| + form, or once a link is on its way, the check-your-email state that |
| + replaces it. rastrillo/auth's own handlers never render a page |
| + (magic-links.md: "the sign-in page stays yours"): Begin, Callback and |
| + Verify report every outcome by redirecting back here with a query the |
| + handler reads. |
| The form POSTs straight to rastrillo/auth's own Begin behind |
| - gateBegin (app.go) — no handler of this app's own sits in that |
| - path. The hidden force=1 field says "plain magic link": this app |
| - never offers a keymail or passkey ceremony (spec §8.1, auth.go's |
| - package comment), so there is nothing for a person to choose |
| - between, and force skips signin's classification network call |
| - outright. gateBegin sets it again server-side, so the guarantee |
| - does not depend on this field surviving. |
| - |
| - Bare rst-* attribute grammar throughout, never class="rst-*", and |
| - no JavaScript: a form and a submit button are the whole screen. */}} |
| -{{define "title"}}{{.Title}}{{end}} |
| -{{/* The app's name, not a link. Everything the shell bar can navigate |
| - to on this screen is behind the session gate, so the default |
| - brand — an <a href="/"> — would bounce straight back to the page |
| - it was clicked from. The account disclosure removes itself |
| - (tokens.css hides [rst-shell-account] when its menu is empty, and |
| - this page defines no "account" block), which leaves only the |
| - narrow-viewport hamburger, hidden below. */}} |
| -{{define "brand"}}<span rst-shell-brand estilo-brand><img estilo-brand-icon src="{{asset "static/brand/icon.svg"}}" alt="" width="28" height="28">Slides</span>{{end}} |
| -{{define "head"}}<style> |
| - /* The hamburger opens the bar's tail — nav, account, locale — and on |
| - this page every one of those is empty. A control that visibly does |
| - nothing is worse than no control, so it goes. |
| - |
| - display: none, not visibility or opacity: it must leave the |
| - accessibility tree too, or a screen-reader user tabs into a |
| - disclosure nobody can see. The bar itself stays: naming the app on |
| - its own front door is worth a strip of chrome. |
| - |
| - Written as a descendant of [rst-shell-topbar] to match the |
| - framework's own selector, and that is load-bearing rather than |
| - stylistic: tokens.css lays the control out with |
| - `[rst-shell-topbar] [rst-shell-menu]`, specificity (0,2,0), which a |
| - bare `[rst-shell-menu]` at (0,1,0) would lose to. */ |
| - [rst-shell-topbar] [rst-shell-menu] { |
| - display: none; |
| - } |
| - /* One measure for the whole screen, shared by the callouts and the |
| - box. Without it the box fills the shell's full column and a single |
| - email field stretches the width of a data table, with its submit |
| - button somewhere off to the right — the shape of markup nobody has |
| - laid out, on the one screen a person meets before they have any |
| - reason to extend this app credit. |
| - |
| - Left-aligned rather than centred, so the box hangs off the same |
| - axis as the heading above it and as every other screen. */ |
| - [slides-signin] { |
| - max-inline-size: 32rem; |
| - } |
| - /* The field and the button stack, rather than sharing a line. One |
| - labelled field over its own submit is the shape that survives a |
| - 320px viewport without either control being squeezed, and it keeps |
| - the visible label directly above the input it names. |
| - |
| - min-inline-size: 0 on the input because an <input>'s automatic |
| - minimum size is its content width: without the explicit 0 it |
| - refuses to shrink and the box overflows instead of reflowing. */ |
| - [slides-signin-form] [rst-input] { |
| - inline-size: 100%; |
| - min-inline-size: 0; |
| - } |
| -</style>{{end}} |
| -{{define "content"}} |
| -<header rst-page-header> |
| - <div rst-page-header-titles> |
| - <h1>{{.Title}}</h1> |
| - </div> |
| -</header> |
| -<div slides-signin> |
| - {{/* The callouts live inside the same column as the box, not above |
| - it at the shell's full width: an answer to what was just |
| - submitted that runs twice the width of the thing it refers to |
| - reads as a banner about the app. */}} |
| - {{if .Sent}} |
| - {{template "callout" dict "Body" .Sent "Tone" "positive"}} |
| - {{end}} |
| - {{if .ErrorMessage}} |
| - {{template "callout" dict "Body" .ErrorMessage "Tone" "negative" "Alert" true}} |
| - {{end}} |
| - <div rst-box> |
| - <p>{{.Body}}</p> |
| - {{/* field-text and form-foot rather than a hand-rolled label and |
| - button: AGENTS.md marks fields and form actions as vendored |
| - components to use, not to reimplement, and what they carry is |
| - the wiring that is easy to get subtly wrong — a real <label> |
| - bound to the input's id, aria-describedby listing exactly the |
| - ids that render, aria-invalid on an error. A placeholder is |
| - not a label. */}} |
| - <form slides-signin-form rst-form method="post" action="/signin"> |
| - <input type="hidden" name="force" value="1"> |
| - {{template "field-text" dict |
| - "Name" "address" |
| - "Label" .AddressLabel |
| - "Type" "email" |
| - "Autocomplete" "email" |
| - "Required" true |
| - "Autofocus" true}} |
| - {{template "form-foot" dict "Submit" .SubmitLabel}} |
| - </form> |
| - </div> |
| -</div> |
| -{{end}} |
| + gateBegin (app.go). The hidden force=1 says "plain magic link": this |
| + app never offers a keymail or passkey ceremony, and gateBegin sets it |
| + again server-side, so the guarantee does not depend on this field. |
| + The bar shows the brand only: nobody is signed in here to have an |
| + account, and everything the brand could link to is behind the |
| + session gate. Data: signinView. */}} |
| +{{define "content"}}{{with .Sent}}{{template "estilo-signin-sent" .}}{{else}}{{template "estilo-signin" .SignIn}}{{end}}{{end}} |
diff --git a/internal/slides/templates/viewer.html b/internal/slides/templates/viewer.html| index 076ac80..f79a682 100644 |
| --- a/internal/slides/templates/viewer.html |
| +++ b/internal/slides/templates/viewer.html |
| @@ -16,7 +16,6 @@ |
| links, nothing else on the page. §8's floor, and the thing the |
| fullscreen presenter will be an enhancement OF rather than a |
| replacement for. */}} |
| -{{define "title"}}{{.Title}}{{end}} |
| {{/* The slide's own stylesheet, on the screens that render a slide and |
| nowhere else: it is a document's look, not the app's chrome, and |
| every other screen would pay for it without using it. */}} |
| @@ -139,7 +138,7 @@ |
| </nav> |
| {{else}} |
| <div rst-box> |
| - {{template "empty-state" dict "Title" .EmptyTitle "Body" .EmptyBody}} |
| + {{template "empty-state" dict "Title" .EmptyTitle "Body" .EmptyBody "ActionHref" .EditHref "ActionLabel" .EditLabel}} |
| </div> |
| {{end}} |
| </div> |
diff --git a/internal/slides/viewer.go b/internal/slides/viewer.go| index 9375d3d..d416358 100644 |
| --- a/internal/slides/viewer.go |
| +++ b/internal/slides/viewer.go |
| @@ -119,12 +119,6 @@ type viewerPage struct { |
| BackHref string |
| BackLabel string |
| - |
| - // SignInHref and SignInLabel are the share-link shell's one control |
| - // (templates/anon.html): somebody reading a deck over a link has no |
| - // account menu, and the honest thing to offer instead is the door. |
| - SignInHref string |
| - SignInLabel string |
| } |
| // --- GET /d/{sqid} --- |
| @@ -146,7 +140,7 @@ func (a *app) showDeck(w http.ResponseWriter, r *http.Request) { |
| if d.OwnerID == u.ID { |
| mode = modeOwner |
| } |
| - a.renderViewer(w, r, d, mode, "/d/"+d.Sqid, shellFor(r, u)) |
| + a.renderViewer(w, r, d, mode, "/d/"+d.Sqid, a.shellFor(r, u, titled(d.Title))) |
| } |
| // renderViewer is the one viewer, for all three audiences. base is the |
| @@ -169,13 +163,11 @@ func (a *app) renderViewer(w http.ResponseWriter, r *http.Request, d Deck, mode, |
| present := q.Get("present") == "1" |
| page := viewerPage{ |
| - shell: sh, |
| - Mode: mode, |
| - Present: present, |
| - VP: vp, |
| - Title: view.Meta.Title, |
| - SignInHref: signinPath, |
| - SignInLabel: rastrillo.T(r, "slides.signin.title"), |
| + shell: sh, |
| + Mode: mode, |
| + Present: present, |
| + VP: vp, |
| + Title: view.Meta.Title, |
| } |
| if mode != modeLink { |
| page.BackHref = "/" |
| @@ -227,7 +219,13 @@ func (a *app) renderViewer(w http.ResponseWriter, r *http.Request, d Deck, mode, |
| if len(view.Slides) == 0 { |
| page.EmptyTitle = rastrillo.T(r, "slides.viewer.empty.title") |
| - page.EmptyBody = rastrillo.T(r, "slides.viewer.empty.body") |
| + // Somebody who can edit is told how to fill it; a stranger |
| + // holding a share link only that it is empty (the audit found |
| + // this line telling people to read docs/api.md). |
| + page.EmptyBody = rastrillo.T(r, "slides.viewer.empty.body.link") |
| + if page.EditHref != "" { |
| + page.EmptyBody = rastrillo.T(r, "slides.viewer.empty.body") |
| + } |
| render(w, viewerTemplate(mode), page) |
| return |
| } |
| @@ -425,7 +423,7 @@ func (a *app) deckNotes(w http.ResponseWriter, r *http.Request) { |
| base := "/d/" + d.Sqid |
| page := notesPage{ |
| - shell: shellFor(r, u), |
| + shell: a.shellFor(r, u, onDeck(rastrillo.T(r, "slides.notes.title"), view.Meta.Title)), |
| Title: rastrillo.T(r, "slides.notes.title"), |
| DeckTitle: view.Meta.Title, |
| Body: rastrillo.T(r, "slides.notes.body"), |