oficina / slides Public

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>
Paul Campbell pushed by paul@keymail.dev 4e853dc5c56af9cde78d69cb2238f7741b7fd89f parent a88f7ff
43 files changed, +1215 −587
  • cmd/slides/main.go +10 −3
  • go.mod +3 −2
  • go.sum +4 −4
  • internal/slides/agents.go +4 −1
  • internal/slides/app.go +32 −4
  • internal/slides/auth.go +264 −30
  • internal/slides/chrome.go +258 −0
  • internal/slides/credits.go +21 −6
  • internal/slides/decks.go +7 −34
  • internal/slides/editor.go +6 −3
  • internal/slides/errors.go +246 −0
  • internal/slides/exports.go +2 −2
  • internal/slides/handlers.go +9 −0
  • internal/slides/home.go +23 −16
  • internal/slides/locales/en.toml +80 −42
  • internal/slides/presentation.go +37 −42
  • internal/slides/render.go +96 −76
  • internal/slides/share.go +13 −2
  • internal/slides/templates/account.html +0 −50
  • internal/slides/templates/agents.html +1 −2
  • internal/slides/templates/anon.html +0 −16
  • internal/slides/templates/credits.html +1 −3
  • internal/slides/templates/deck_delete.html +0 −1
  • internal/slides/templates/deck_export.html +1 −2
  • internal/slides/templates/deck_new.html +0 −1
  • internal/slides/templates/deck_notes.html +1 −2
  • internal/slides/templates/deck_settings.html +0 −1
  • internal/slides/templates/decks.html +0 −1
  • internal/slides/templates/editor.html +0 −1
  • internal/slides/templates/errors.html +8 −16
  • internal/slides/templates/export_status.html +0 −1
  • internal/slides/templates/help.html +0 −4
  • internal/slides/templates/home_link.html +0 −1
  • internal/slides/templates/layout.html +51 −83
  • internal/slides/templates/present.html +0 −1
  • internal/slides/templates/present_notes.html +1 −2
  • internal/slides/templates/remote.html +0 −1
  • internal/slides/templates/remote_pair.html +0 −1
  • internal/slides/templates/share.html +2 −3
  • internal/slides/templates/signed_out.html +6 −0
  • internal/slides/templates/signin.html +13 −109
  • internal/slides/templates/viewer.html +1 −2
  • internal/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"),